improved changes
This commit is contained in:
@ -53,6 +53,7 @@ def create_app():
|
||||
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SQLALCHEMY_ECHO'] = os.getenv('SQLALCHEMY_ECHO', 'False').lower() == 'true'
|
||||
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
|
||||
BIN
Machine-Backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
Machine-Backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Machine-Backend/app/models/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
Machine-Backend/app/models/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Machine-Backend/app/models/__pycache__/models.cpython-314.pyc
Normal file
BIN
Machine-Backend/app/models/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
@ -4,7 +4,35 @@ import time
|
||||
import json
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
# Machine Model
|
||||
class RefillerMachine(db.Model):
|
||||
__tablename__ = 'refiller_machines'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
refiller_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
machine_id = db.Column(db.String(10), db.ForeignKey('machines.machine_id', ondelete='CASCADE'), nullable=False)
|
||||
assigned_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
assigned_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
assigner = db.relationship('User', foreign_keys=[assigned_by], backref='machine_assignments_made')
|
||||
|
||||
# Unique constraint: one refiller can't be assigned to same machine twice
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('refiller_id', 'machine_id', name='unique_refiller_machine'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'refiller_id': self.refiller_id,
|
||||
'machine_id': self.machine_id,
|
||||
'assigned_at': self.assigned_at.strftime("%Y-%m-%d %H:%M:%S") if self.assigned_at else None,
|
||||
'assigned_by': self.assigned_by,
|
||||
'assigned_by_username': self.assigner.username if self.assigner else None
|
||||
}
|
||||
|
||||
|
||||
# Machine Model - UPDATED
|
||||
class Machine(db.Model):
|
||||
__tablename__ = 'machines'
|
||||
|
||||
@ -20,10 +48,37 @@ class Machine(db.Model):
|
||||
connection_status = db.Column(db.String(50), nullable=False)
|
||||
created_on = db.Column(db.String(20), nullable=False)
|
||||
password = db.Column(db.String(128), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
slots = db.relationship('VendingSlot', backref='machine', lazy=True)
|
||||
client = db.relationship('User', backref='machines')
|
||||
client = db.relationship('User', foreign_keys=[client_id], backref='client_machines')
|
||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_machines')
|
||||
|
||||
# ⭐ NEW: Many-to-many relationship with Refillers through RefillerMachine
|
||||
assigned_refillers = db.relationship(
|
||||
'User',
|
||||
secondary='refiller_machines',
|
||||
primaryjoin='Machine.machine_id == RefillerMachine.machine_id',
|
||||
secondaryjoin='and_(User.id == RefillerMachine.refiller_id, User.roles == "Refiller")',
|
||||
backref='assigned_machines_rel',
|
||||
viewonly=True
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
# Get assigned refillers
|
||||
refiller_assignments = RefillerMachine.query.filter_by(machine_id=self.machine_id).all()
|
||||
assigned_refillers = []
|
||||
for assignment in refiller_assignments:
|
||||
refiller = User.query.get(assignment.refiller_id)
|
||||
if refiller:
|
||||
assigned_refillers.append({
|
||||
'id': refiller.id,
|
||||
'username': refiller.username,
|
||||
'email': refiller.email,
|
||||
'assigned_at': assignment.assigned_at.strftime("%Y-%m-%d %H:%M:%S") if assignment.assigned_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'machine_id': self.machine_id,
|
||||
@ -36,7 +91,10 @@ class Machine(db.Model):
|
||||
'operation_status': self.operation_status,
|
||||
'connection_status': self.connection_status,
|
||||
'created_on': self.created_on,
|
||||
'password': self.password
|
||||
'password': self.password,
|
||||
'created_by': self.created_by,
|
||||
'created_by_username': self.creator.username if self.creator else None,
|
||||
'assigned_refillers': assigned_refillers # ⭐ NEW
|
||||
}
|
||||
|
||||
def set_password(self, password):
|
||||
@ -46,7 +104,7 @@ class Machine(db.Model):
|
||||
return check_password_hash(self.password, password)
|
||||
|
||||
|
||||
# User Model - UPDATED with proper password hashing
|
||||
# User Model - UPDATED WITH BOTH CLIENT AND MACHINE ASSIGNMENTS
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
@ -57,7 +115,9 @@ class User(db.Model):
|
||||
contact = db.Column(db.String(20), nullable=False)
|
||||
roles = db.Column(db.String(50), nullable=False)
|
||||
user_status = db.Column(db.String(50), nullable=False)
|
||||
password = db.Column(db.String(255), nullable=False) # Increased length for hash
|
||||
password = db.Column(db.String(255), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # Assigned to Client
|
||||
|
||||
# File storage fields
|
||||
photo = db.Column(db.String(255), nullable=True)
|
||||
@ -67,9 +127,27 @@ class User(db.Model):
|
||||
# Timestamps
|
||||
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
creator = db.relationship('User', remote_side=[id], backref='created_users', foreign_keys=[created_by])
|
||||
assigned_client = db.relationship('User', remote_side=[id], backref='assigned_refillers', foreign_keys=[assigned_to])
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert user object to dictionary"""
|
||||
# Get assigned machines for Refillers
|
||||
assigned_machines = []
|
||||
if self.roles == 'Refiller':
|
||||
machine_assignments = RefillerMachine.query.filter_by(refiller_id=self.id).all()
|
||||
for assignment in machine_assignments:
|
||||
machine = Machine.query.filter_by(machine_id=assignment.machine_id).first()
|
||||
if machine:
|
||||
assigned_machines.append({
|
||||
'machine_id': machine.machine_id,
|
||||
'machine_model': machine.machine_model,
|
||||
'branch_name': machine.branch_name,
|
||||
'assigned_at': assignment.assigned_at.strftime("%Y-%m-%d %H:%M:%S") if assignment.assigned_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
@ -83,7 +161,12 @@ class User(db.Model):
|
||||
'documents': json.loads(self.documents) if self.documents else [],
|
||||
'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None,
|
||||
'machines': [m.to_dict() for m in self.machines] if hasattr(self, 'machines') else []
|
||||
'created_by': self.created_by,
|
||||
'created_by_username': self.creator.username if self.creator else None,
|
||||
'assigned_to': self.assigned_to,
|
||||
'assigned_to_username': self.assigned_client.username if self.assigned_client else None,
|
||||
'assigned_machines': assigned_machines, # ⭐ NEW
|
||||
'assigned_machines_count': len(assigned_machines) # ⭐ NEW
|
||||
}
|
||||
|
||||
def set_password(self, password):
|
||||
@ -125,6 +208,13 @@ class Product(db.Model):
|
||||
price = db.Column(db.Float, nullable=False)
|
||||
product_image = db.Column(db.String(255), nullable=False)
|
||||
created_date = db.Column(db.String(20), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
# NEW: Billing and expiration dates
|
||||
billing_date = db.Column(db.DateTime, nullable=True)
|
||||
expiration_date = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationship
|
||||
creator = db.relationship('User', backref='created_products')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@ -133,7 +223,11 @@ class Product(db.Model):
|
||||
'product_name': self.product_name,
|
||||
'price': str(self.price),
|
||||
'product_image': self.product_image,
|
||||
'created_date': self.created_date
|
||||
'created_date': self.created_date,
|
||||
'billing_date': self.billing_date.strftime("%Y-%m-%d") if self.billing_date else None,
|
||||
'expiration_date': self.expiration_date.strftime("%Y-%m-%d") if self.expiration_date else None,
|
||||
'created_by': self.created_by,
|
||||
'created_by_username': self.creator.username if self.creator else None
|
||||
}
|
||||
|
||||
|
||||
@ -191,7 +285,9 @@ class Transaction(db.Model):
|
||||
'return_amount': self.return_amount,
|
||||
'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None
|
||||
}
|
||||
# Add to your models.py
|
||||
|
||||
|
||||
# Role Model
|
||||
class Role(db.Model):
|
||||
__tablename__ = 'roles'
|
||||
|
||||
@ -199,15 +295,260 @@ class Role(db.Model):
|
||||
name = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
permissions = db.Column(db.Text) # JSON string of permission IDs
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
creator = db.relationship('User', backref='created_roles')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'permissions': json.loads(self.permissions) if self.permissions else [],
|
||||
'created_by': self.created_by,
|
||||
'created_by_username': self.creator.username if self.creator else None,
|
||||
'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Branch Model
|
||||
class Branch(db.Model):
|
||||
__tablename__ = 'branches'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
branch_id = db.Column(db.String(50), unique=True, nullable=False)
|
||||
code = db.Column(db.String(20), unique=True, nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
location = db.Column(db.String(100), nullable=False)
|
||||
address = db.Column(db.Text, nullable=False)
|
||||
contact = db.Column(db.String(20), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
creator = db.relationship('User', backref='created_branches')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'branch_id': self.branch_id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'location': self.location,
|
||||
'address': self.address,
|
||||
'contact': self.contact,
|
||||
'created_by': self.created_by,
|
||||
'created_by_username': self.creator.username if self.creator else None,
|
||||
'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_branch_id():
|
||||
"""Generate unique branch ID with BR prefix"""
|
||||
import uuid
|
||||
while True:
|
||||
branch_id = f"BR{uuid.uuid4().hex[:8].upper()}"
|
||||
if not Branch.query.filter_by(branch_id=branch_id).first():
|
||||
return branch_id
|
||||
|
||||
# Brand Model
|
||||
class Brand(db.Model):
|
||||
__tablename__ = 'brands'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
brand_id = db.Column(db.String(50), unique=True, nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
branch_id = db.Column(db.String(50), db.ForeignKey('branches.branch_id'), nullable=False)
|
||||
branch_name = db.Column(db.String(100), nullable=False)
|
||||
image = db.Column(db.String(255), nullable=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
branch = db.relationship('Branch', backref='brands', foreign_keys=[branch_id])
|
||||
creator = db.relationship('User', backref='created_brands')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'brand_id': self.brand_id,
|
||||
'name': self.name,
|
||||
'branch_id': self.branch_id,
|
||||
'branch_name': self.branch_name,
|
||||
'image': self.image,
|
||||
'created_by': self.created_by,
|
||||
'created_by_username': self.creator.username if self.creator else None,
|
||||
'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_brand_id(name):
|
||||
"""
|
||||
Generate brand ID from first 3 letters of name + 4-digit sequence
|
||||
Example: CocaCola -> COC0001, COC0002, etc.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Extract only letters from name and get first 3
|
||||
letters_only = ''.join(filter(str.isalpha, name)).upper()
|
||||
prefix = letters_only[:3].ljust(3, 'X') # Pad with X if less than 3 letters
|
||||
|
||||
# Find the highest existing sequence number for this prefix
|
||||
existing_brands = Brand.query.filter(
|
||||
Brand.brand_id.like(f"{prefix}%")
|
||||
).all()
|
||||
|
||||
if not existing_brands:
|
||||
sequence = 1
|
||||
else:
|
||||
# Extract sequence numbers and find max
|
||||
sequences = []
|
||||
for brand in existing_brands:
|
||||
try:
|
||||
seq_part = brand.brand_id[3:] # Get part after prefix
|
||||
if seq_part.isdigit():
|
||||
sequences.append(int(seq_part))
|
||||
except:
|
||||
continue
|
||||
|
||||
sequence = max(sequences) + 1 if sequences else 1
|
||||
|
||||
# Format: PREFIX + 4-digit sequence
|
||||
brand_id = f"{prefix}{sequence:04d}"
|
||||
|
||||
return brand_id
|
||||
|
||||
# Category Model
|
||||
class Category(db.Model):
|
||||
__tablename__ = 'categories'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category_id = db.Column(db.String(50), unique=True, nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
image = db.Column(db.String(255), nullable=True)
|
||||
brand_id = db.Column(db.String(50), db.ForeignKey('brands.brand_id'), nullable=False)
|
||||
brand_name = db.Column(db.String(100), nullable=False)
|
||||
branch_id = db.Column(db.String(50), db.ForeignKey('branches.branch_id'), nullable=False)
|
||||
branch_name = db.Column(db.String(100), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
brand = db.relationship('Brand', backref='categories', foreign_keys=[brand_id])
|
||||
branch = db.relationship('Branch', backref='categories', foreign_keys=[branch_id])
|
||||
creator = db.relationship('User', backref='created_categories')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'category_id': self.category_id,
|
||||
'name': self.name,
|
||||
'image': self.image,
|
||||
'brand_id': self.brand_id,
|
||||
'brand_name': self.brand_name,
|
||||
'branch_id': self.branch_id,
|
||||
'branch_name': self.branch_name,
|
||||
'created_by': self.created_by,
|
||||
'created_by_username': self.creator.username if self.creator else None,
|
||||
'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_category_id(name):
|
||||
"""
|
||||
Generate category ID from first 3 letters of name + 4-digit sequence
|
||||
Example: Beverages -> BEV0001, BEV0002, etc.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Extract only letters from name and get first 3
|
||||
letters_only = ''.join(filter(str.isalpha, name)).upper()
|
||||
prefix = letters_only[:3].ljust(3, 'X') # Pad with X if less than 3 letters
|
||||
|
||||
# Find the highest existing sequence number for this prefix
|
||||
existing_categories = Category.query.filter(
|
||||
Category.category_id.like(f"{prefix}%")
|
||||
).all()
|
||||
|
||||
if not existing_categories:
|
||||
sequence = 1
|
||||
else:
|
||||
# Extract sequence numbers and find max
|
||||
sequences = []
|
||||
for category in existing_categories:
|
||||
try:
|
||||
seq_part = category.category_id[3:] # Get part after prefix
|
||||
if seq_part.isdigit():
|
||||
sequences.append(int(seq_part))
|
||||
except:
|
||||
continue
|
||||
|
||||
sequence = max(sequences) + 1 if sequences else 1
|
||||
|
||||
# Format: PREFIX + 4-digit sequence
|
||||
category_id = f"{prefix}{sequence:04d}"
|
||||
|
||||
return category_id
|
||||
|
||||
class SubCategory(db.Model):
|
||||
__tablename__ = 'sub_categories'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
sub_category_id = db.Column(db.String(50), unique=True, nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
image = db.Column(db.String(255), nullable=True)
|
||||
category_id = db.Column(db.String(50), db.ForeignKey('categories.category_id'), nullable=False)
|
||||
category_name = db.Column(db.String(100), nullable=False)
|
||||
brand_id = db.Column(db.String(50), db.ForeignKey('brands.brand_id'), nullable=False)
|
||||
brand_name = db.Column(db.String(100), nullable=False)
|
||||
branch_id = db.Column(db.String(50), db.ForeignKey('branches.branch_id'), nullable=False)
|
||||
branch_name = db.Column(db.String(100), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
category = db.relationship('Category', backref='sub_categories', foreign_keys=[category_id])
|
||||
brand = db.relationship('Brand', backref='sub_categories', foreign_keys=[brand_id])
|
||||
branch = db.relationship('Branch', backref='sub_categories', foreign_keys=[branch_id])
|
||||
creator = db.relationship('User', backref='created_subcategories')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'sub_category_id': self.sub_category_id,
|
||||
'name': self.name,
|
||||
'image': self.image,
|
||||
'category_id': self.category_id,
|
||||
'category_name': self.category_name,
|
||||
'brand_id': self.brand_id,
|
||||
'brand_name': self.brand_name,
|
||||
'branch_id': self.branch_id,
|
||||
'branch_name': self.branch_name,
|
||||
'created_by': self.created_by,
|
||||
'created_by_username': self.creator.username if self.creator else None,
|
||||
'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_sub_category_id(name):
|
||||
letters_only = ''.join(filter(str.isalpha, name)).upper()
|
||||
prefix = letters_only[:3].ljust(3, 'X')
|
||||
existing = SubCategory.query.filter(SubCategory.sub_category_id.like(f"{prefix}%")).all()
|
||||
if not existing:
|
||||
sequence = 1
|
||||
else:
|
||||
sequences = [int(s.sub_category_id[3:]) for s in existing if s.sub_category_id[3:].isdigit()]
|
||||
sequence = max(sequences) + 1 if sequences else 1
|
||||
return f"{prefix}{sequence:04d}"
|
||||
BIN
Machine-Backend/app/routes/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
Machine-Backend/app/routes/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Machine-Backend/app/routes/__pycache__/routes.cpython-314.pyc
Normal file
BIN
Machine-Backend/app/routes/__pycache__/routes.cpython-314.pyc
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
Binary file not shown.
0
Machine-Backend/backup.sql
Normal file
0
Machine-Backend/backup.sql
Normal file
255
Machine-Backend/backup_db.py
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
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()
|
||||
@ -1,38 +1,123 @@
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import OperationalError
|
||||
"""
|
||||
Database Migration Script for Categories Table
|
||||
"""
|
||||
|
||||
def wait_for_db(max_retries=30, retry_delay=2):
|
||||
"""Wait for database to be ready"""
|
||||
from app import db, create_app
|
||||
from sqlalchemy import text
|
||||
|
||||
def create_categories_table():
|
||||
"""Create categories table"""
|
||||
|
||||
mysql_host = os.getenv('MYSQL_HOST', 'db')
|
||||
mysql_user = os.getenv('MYSQL_USER', 'vendinguser')
|
||||
mysql_password = os.getenv('MYSQL_PASSWORD', 'vendingpass')
|
||||
mysql_db = os.getenv('MYSQL_DATABASE', 'vending')
|
||||
create_table_sql = """
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id VARCHAR(50) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
image VARCHAR(255),
|
||||
brand_id VARCHAR(50) NOT NULL,
|
||||
brand_name VARCHAR(100) NOT NULL,
|
||||
branch_id VARCHAR(50) NOT NULL,
|
||||
branch_name VARCHAR(100) NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (brand_id) REFERENCES brands(brand_id),
|
||||
FOREIGN KEY (branch_id) REFERENCES branches(branch_id)
|
||||
);
|
||||
"""
|
||||
|
||||
db_uri = f'mysql+pymysql://{mysql_user}:{mysql_password}@{mysql_host}:3306/{mysql_db}'
|
||||
create_index_sqls = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_category_id ON categories(category_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_category_brand_id ON categories(brand_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_category_branch_id ON categories(branch_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_category_name ON categories(name);"
|
||||
]
|
||||
|
||||
print(f"⏳ Waiting for MySQL at {mysql_host}:3306...")
|
||||
try:
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
with db.engine.connect() as connection:
|
||||
# Create the categories table
|
||||
connection.execute(text(create_table_sql))
|
||||
|
||||
# Create each index separately (SQLite limitation)
|
||||
for sql in create_index_sqls:
|
||||
connection.execute(text(sql))
|
||||
|
||||
connection.commit()
|
||||
|
||||
print("✓ Categories table created successfully!")
|
||||
print("✓ Indexes created successfully!")
|
||||
|
||||
# Optional: add sample categories
|
||||
add_sample = input("\nAdd sample categories? (y/n): ")
|
||||
if add_sample.lower() == 'y':
|
||||
add_sample_categories()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error creating table: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def add_sample_categories():
|
||||
"""Add sample category data"""
|
||||
from app.models.models import Category, Brand, Branch
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
engine = create_engine(db_uri)
|
||||
connection = engine.connect()
|
||||
connection.close()
|
||||
print("✓ MySQL is ready!")
|
||||
return True
|
||||
except OperationalError as e:
|
||||
if attempt < max_retries:
|
||||
print(f"Waiting for MySQL... (attempt {attempt}/{max_retries})")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
print(f"❌ Failed to connect to MySQL after {max_retries} attempts")
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
brands = Brand.query.all()
|
||||
branches = Branch.query.all()
|
||||
|
||||
return False
|
||||
if not brands or not branches:
|
||||
print("✗ Please create brands and branches first!")
|
||||
return
|
||||
|
||||
sample_categories = [
|
||||
{'name': 'Beverages', 'brand_id': brands[0].brand_id if len(brands) > 0 else None},
|
||||
{'name': 'Snacks', 'brand_id': brands[1].brand_id if len(brands) > 1 else brands[0].brand_id},
|
||||
{'name': 'Chocolates', 'brand_id': brands[1].brand_id if len(brands) > 1 else brands[0].brand_id}
|
||||
]
|
||||
|
||||
try:
|
||||
for cat_data in sample_categories:
|
||||
if not cat_data['brand_id']:
|
||||
continue
|
||||
|
||||
brand = Brand.query.filter_by(brand_id=cat_data['brand_id']).first()
|
||||
if not brand:
|
||||
continue
|
||||
|
||||
branch = Branch.query.filter_by(branch_id=brand.branch_id).first()
|
||||
if not branch:
|
||||
continue
|
||||
|
||||
category_id = Category.generate_category_id(cat_data['name'])
|
||||
|
||||
category = Category(
|
||||
category_id=category_id,
|
||||
name=cat_data['name'],
|
||||
brand_id=brand.brand_id,
|
||||
brand_name=brand.name,
|
||||
branch_id=branch.branch_id,
|
||||
branch_name=branch.name,
|
||||
image=None
|
||||
)
|
||||
|
||||
db.session.add(category)
|
||||
|
||||
db.session.commit()
|
||||
print(f"✓ Added sample categories successfully!")
|
||||
|
||||
# Display all created categories
|
||||
categories = Category.query.all()
|
||||
print("\nCreated categories:")
|
||||
for cat in categories:
|
||||
print(f" - {cat.category_id}: {cat.name} @ {cat.brand_name} ({cat.branch_name})")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"✗ Error adding sample data: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
wait_for_db()
|
||||
create_categories_table()
|
||||
|
||||
@ -15,8 +15,8 @@ CORS(app,
|
||||
)
|
||||
|
||||
# Configuration - point to your main backend
|
||||
# MAIN_BACKEND_URL = "http://127.0.0.1:5000"
|
||||
MAIN_BACKEND_URL = "https://iotbackend.rootxwire.com" # Change this to your main backend URL
|
||||
MAIN_BACKEND_URL = "http://127.0.0.1:5000"
|
||||
# MAIN_BACKEND_URL = "https://iotbackend.rootxwire.com" # Change this to your main backend URL
|
||||
|
||||
# Add OPTIONS handler for preflight requests
|
||||
@app.before_request
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
// app/core/services/role-state.service.ts
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
export interface RoleUpdate {
|
||||
roleId: number;
|
||||
roleName: string;
|
||||
permissions: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class RoleStateService {
|
||||
// Track when roles are updated
|
||||
private _roleUpdated = new BehaviorSubject<RoleUpdate | null>(null);
|
||||
public roleUpdated$ = this._roleUpdated.asObservable();
|
||||
|
||||
// Track when a role is deleted
|
||||
private _roleDeleted = new BehaviorSubject<number | null>(null);
|
||||
public roleDeleted$ = this._roleDeleted.asObservable();
|
||||
|
||||
// Track when a role is created
|
||||
private _roleCreated = new BehaviorSubject<RoleUpdate | null>(null);
|
||||
public roleCreated$ = this._roleCreated.asObservable();
|
||||
|
||||
constructor() {
|
||||
console.log('✓ RoleStateService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that a role's permissions have been updated
|
||||
*/
|
||||
notifyRoleUpdate(roleId: number, roleName: string, permissions: string[]): void {
|
||||
const update: RoleUpdate = {
|
||||
roleId,
|
||||
roleName,
|
||||
permissions,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log('📢 Role updated:', update);
|
||||
this._roleUpdated.next(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that a role has been deleted
|
||||
*/
|
||||
notifyRoleDeleted(roleId: number): void {
|
||||
console.log('📢 Role deleted:', roleId);
|
||||
this._roleDeleted.next(roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that a role has been created
|
||||
*/
|
||||
notifyRoleCreated(roleId: number, roleName: string, permissions: string[]): void {
|
||||
const update: RoleUpdate = {
|
||||
roleId,
|
||||
roleName,
|
||||
permissions,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log('📢 Role created:', update);
|
||||
this._roleCreated.next(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all notifications
|
||||
*/
|
||||
clear(): void {
|
||||
this._roleUpdated.next(null);
|
||||
this._roleDeleted.next(null);
|
||||
this._roleCreated.next(null);
|
||||
}
|
||||
}
|
||||
@ -120,96 +120,8 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
||||
icon: 'heroicons_outline:user-group',
|
||||
link: '/role-management',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.warehouse',
|
||||
title: 'Warehouse',
|
||||
type: 'collapsable',
|
||||
icon: 'heroicons_outline:truck',
|
||||
link: '/dashboards/warehouse',
|
||||
children: [
|
||||
{
|
||||
id: 'dashboards.Warehouse.warehouse-list',
|
||||
title: 'Warehouse List',
|
||||
type: 'basic',
|
||||
link: '/warehouse/warehouse-list',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.Warehouse.vendors',
|
||||
title: 'Vendors',
|
||||
type: 'basic',
|
||||
link: '/warehouse/vendors',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.Warehouse.current-stock',
|
||||
title: 'Current Stock',
|
||||
type: 'basic',
|
||||
link: '/warehouse/current-stock',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.Warehouse.stock-in-transit',
|
||||
title: 'Stock In Transit',
|
||||
type: 'basic',
|
||||
link: '/warehouse/stock-in-transit',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.Warehouse.returned-stock',
|
||||
title: 'Returned Stock',
|
||||
type: 'basic',
|
||||
link: '/warehouse/returned-stock',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.Warehouse.scrapped-stock',
|
||||
title: 'Scrapped Stock',
|
||||
type: 'basic',
|
||||
link: '/warehouse/scrapped-stock',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dashboards.w-transactions',
|
||||
title: 'W. Transactions',
|
||||
type: 'collapsable',
|
||||
icon: 'heroicons_outline:newspaper',
|
||||
link: '/dashboards/w-transactions',
|
||||
children: [
|
||||
{
|
||||
id: 'dashboards.w-transactions.purchase',
|
||||
title: 'Purchase',
|
||||
type: 'basic',
|
||||
link: '/w-transactions/purchase',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.w-transactions.transfer-request',
|
||||
title: 'Transfer Request',
|
||||
type: 'basic',
|
||||
link: '/w-transactions/transfer-request',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.w-transactions.refill-request',
|
||||
title: 'Refill Request',
|
||||
type: 'basic',
|
||||
link: '/w-transactions/refill-request',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.w-transactions.return-request',
|
||||
title: 'Return Request',
|
||||
type: 'basic',
|
||||
link: '/w-transactions/return-request',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.w-transactions.maintenance-request',
|
||||
title: 'Maintenance Request',
|
||||
type: 'basic',
|
||||
link: '/w-transactions/maintenance-request',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.w-transactions.maintenance-history',
|
||||
title: 'Maintenance History',
|
||||
type: 'basic',
|
||||
link: '/w-transactions/maintenance-history',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Warehouse - Hidden
|
||||
// W. Transactions - Hidden
|
||||
{
|
||||
id: 'dashboards.advertisements',
|
||||
title: 'Advertisements',
|
||||
@ -249,33 +161,7 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dashboards.user-management',
|
||||
title: 'User Management',
|
||||
type: 'collapsable',
|
||||
icon: 'heroicons_outline:user-circle',
|
||||
link: '/dashboards/user-management',
|
||||
children: [
|
||||
{
|
||||
id: 'dashboards.user-management.client.admin-list',
|
||||
title: 'Client Admin List',
|
||||
type: 'basic',
|
||||
link: '/user-management/client-admin-list',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.user-management.client-user-list',
|
||||
title: 'Client User List',
|
||||
type: 'basic',
|
||||
link: '/user-management/client-user-list',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.user-management.validation-group-list',
|
||||
title: 'Validation Group List',
|
||||
type: 'basic',
|
||||
link: '/user-management/validation-group-list',
|
||||
},
|
||||
],
|
||||
},
|
||||
// User Management - Hidden
|
||||
{
|
||||
id: 'dashboards.api-integration',
|
||||
title: 'API Integration',
|
||||
@ -308,18 +194,18 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
||||
type: 'basic',
|
||||
link: '/company/company-admin-list',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.company.company-user-list',
|
||||
title: 'Company User List',
|
||||
type: 'basic',
|
||||
link: '/company/company-user-list',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.company.company-user-role-list',
|
||||
title: 'Company User Role List',
|
||||
type: 'basic',
|
||||
link: 'company/company-user-role-list',
|
||||
},
|
||||
// {
|
||||
// id: 'dashboards.company.company-user-list',
|
||||
// title: 'Company User List',
|
||||
// type: 'basic',
|
||||
// link: '/company/company-user-list',
|
||||
// },
|
||||
// {
|
||||
// id: 'dashboards.company.company-user-role-list',
|
||||
// title: 'Company User Role List',
|
||||
// type: 'basic',
|
||||
// link: 'company/company-user-role-list',
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -349,27 +235,7 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dashboards.offers-coupons',
|
||||
title: 'Offers & Coupons',
|
||||
type: 'collapsable',
|
||||
icon: 'heroicons_outline:banknotes',
|
||||
link: '/dashboards/offers-coupons',
|
||||
children: [
|
||||
{
|
||||
id: 'dashboards.offers-coupons.offers',
|
||||
title: 'Offers',
|
||||
type: 'basic',
|
||||
link: '/offers-coupons/offers',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.offers-coupons.old-offers',
|
||||
title: 'Old Offers',
|
||||
type: 'basic',
|
||||
link: '/offers-coupons/old-offers',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Offers & Coupons - Hidden
|
||||
{
|
||||
id: 'dashboards.help-support',
|
||||
title: 'Help & Support',
|
||||
@ -377,14 +243,7 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
||||
icon: 'heroicons_outline:information-circle',
|
||||
link: '/help-support',
|
||||
},
|
||||
{
|
||||
id: 'dashboards.support-history',
|
||||
title: 'Support History',
|
||||
type: 'basic',
|
||||
icon: 'heroicons_outline:clock',
|
||||
link: '/support-history',
|
||||
},
|
||||
|
||||
// Support History - Hidden
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -395,7 +254,7 @@ export const compactNavigation: FuseNavigationItem[] = [
|
||||
tooltip: 'Dashboards',
|
||||
type: 'aside',
|
||||
icon: 'heroicons_outline:home',
|
||||
children: [], // This will be filled from defaultNavigation so we don't have to manage multiple sets of the same navigation
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
export const futuristicNavigation: FuseNavigationItem[] = [
|
||||
@ -403,7 +262,7 @@ export const futuristicNavigation: FuseNavigationItem[] = [
|
||||
id: 'dashboards',
|
||||
title: 'DASHBOARDS',
|
||||
type: 'group',
|
||||
children: [], // This will be filled from defaultNavigation so we don't have to manage multiple sets of the same navigation
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
export const horizontalNavigation: FuseNavigationItem[] = [
|
||||
@ -412,6 +271,6 @@ export const horizontalNavigation: FuseNavigationItem[] = [
|
||||
title: 'Dashboards',
|
||||
type: 'group',
|
||||
icon: 'heroicons_outline:home',
|
||||
children: [], // This will be filled from defaultNavigation so we don't have to manage multiple sets of the same navigation
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -1 +1,326 @@
|
||||
<p>branch-list works!</p>
|
||||
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
|
||||
<!-- Loader -->
|
||||
@if (isLoading) {
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
|
||||
</div>
|
||||
}
|
||||
<!-- Title -->
|
||||
<div class="text-4xl font-extrabold tracking-tight">Branch Management</div>
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
|
||||
<!-- Search -->
|
||||
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
|
||||
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
|
||||
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
|
||||
[placeholder]="'Search branches'" />
|
||||
</mat-form-field>
|
||||
<!-- Add branch button -->
|
||||
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="createBranch()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||
<span class="ml-2 mr-1">Add Branch</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex flex-auto overflow-hidden">
|
||||
<!-- Branches list -->
|
||||
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
|
||||
@if (branches$ | async; as branches) {
|
||||
@if (branches.length > 0 || selectedBranch) {
|
||||
<div class="grid">
|
||||
<!-- Header -->
|
||||
<div class="branch-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-sm font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
|
||||
matSort matSortDisableClear>
|
||||
<div class="hidden md:block" [mat-sort-header]="'branch_id'">Branch ID</div>
|
||||
<div [mat-sort-header]="'code'">Code</div>
|
||||
<div [mat-sort-header]="'name'">Branch Name</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'location'">Location</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'contact'">Contact</div>
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block">Created By</div>
|
||||
<div class="text-center">Actions</div>
|
||||
</div>
|
||||
|
||||
<!-- New Branch Form Row -->
|
||||
@if (selectedBranch && !selectedBranch.id) {
|
||||
<div class="branch-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-4 md:px-8">
|
||||
<!-- Branch ID -->
|
||||
<div class="hidden truncate md:block">
|
||||
<span class="text-xs font-medium text-blue-600">Auto-generated</span>
|
||||
</div>
|
||||
|
||||
<!-- Code -->
|
||||
<div class="flex items-center">
|
||||
<div class="truncate font-medium text-blue-600">New Branch</div>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="hidden truncate text-blue-600 text-sm">Creating...</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="hidden truncate lg:block text-blue-600 text-sm">-</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="hidden truncate lg:block text-blue-600 text-sm">-</div>
|
||||
|
||||
<!-- ⭐ NEW: Created By (Auto) -->
|
||||
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>Auto</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button mat-icon-button (click)="closeDetails()" matTooltip="Close">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:x-mark'"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Branch Form Details -->
|
||||
<div class="grid">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: selectedBranch }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Existing Branches -->
|
||||
@for (branch of branches; track trackByFn($index, branch)) {
|
||||
<div class="branch-grid grid items-center gap-4 border-b px-6 py-4 hover:bg-gray-50 transition-colors md:px-8">
|
||||
<!-- Branch ID -->
|
||||
<div class="hidden truncate md:block">
|
||||
<span class="text-xs font-mono text-blue-600 font-semibold">{{ branch.branch_id }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Code -->
|
||||
<div class="flex items-center">
|
||||
<div class="truncate">
|
||||
<div class="font-semibold text-gray-900">{{ branch.code }}</div>
|
||||
<div class="text-xs text-gray-500 md:hidden">{{ branch.branch_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="truncate">
|
||||
<div class="font-medium text-gray-900">{{ branch.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="hidden truncate lg:block">
|
||||
<div class="text-sm text-gray-600">{{ branch.location }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="hidden truncate lg:block">
|
||||
<div class="text-sm text-gray-600">{{ branch.contact }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>{{ branch.created_by_username || 'N/A' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button mat-icon-button (click)="toggleDetails(branch.id)"
|
||||
[matTooltip]="selectedBranch?.id === branch.id ? 'Hide Details' : 'View Details'">
|
||||
<mat-icon class="icon-size-5 text-gray-600" [svgIcon]="
|
||||
selectedBranch?.id === branch.id
|
||||
? 'heroicons_outline:chevron-up'
|
||||
: 'heroicons_outline:chevron-down'
|
||||
"></mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="editBranch(branch)" matTooltip="Edit">
|
||||
<mat-icon class="icon-size-5 text-blue-600" [svgIcon]="'heroicons_outline:pencil'"></mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="deleteBranch(branch)" matTooltip="Delete">
|
||||
<mat-icon class="icon-size-5 text-red-600" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branch Details -->
|
||||
@if (selectedBranch?.id === branch.id) {
|
||||
<div class="grid">
|
||||
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: branch }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
|
||||
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
|
||||
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
|
||||
[showFirstLastButtons]="true"></mat-paginator>
|
||||
} @else {
|
||||
<!-- Empty State -->
|
||||
<div class="flex flex-col items-center justify-center p-16">
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 mb-6">
|
||||
<mat-icon class="icon-size-16 text-gray-400" [svgIcon]="'heroicons_outline:building-office'"></mat-icon>
|
||||
</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-900 mb-2">No branches found</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">Get started by creating your first branch</p>
|
||||
<button mat-flat-button [color]="'primary'" (click)="createBranch()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||
<span class="ml-2">Add Branch</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Row Details Template -->
|
||||
<ng-template #rowDetailsTemplate let-branch>
|
||||
<div class="overflow-hidden bg-white border-b">
|
||||
<div class="flex">
|
||||
<form class="flex w-full flex-col" [formGroup]="selectedBranchForm">
|
||||
<div class="p-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 pb-6 border-b">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
{{ branch.id ? 'Edit Branch' : 'Create New Branch' }}
|
||||
</h2>
|
||||
@if (branch.branch_id) {
|
||||
<p class="text-sm text-gray-500 mt-1">Branch ID: <span class="font-mono text-blue-600">{{ branch.branch_id }}</span></p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Branch Code -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch Code</mat-label>
|
||||
<input matInput [formControlName]="'code'" placeholder="e.g., BR001" />
|
||||
<mat-error *ngIf="selectedBranchForm.get('code')?.hasError('required')">
|
||||
Code is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="selectedBranchForm.get('code')?.hasError('pattern')">
|
||||
Use uppercase letters and numbers only
|
||||
</mat-error>
|
||||
<mat-hint>Unique branch identifier</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Branch Name -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch Name</mat-label>
|
||||
<input matInput [formControlName]="'name'" placeholder="e.g., Main Branch" />
|
||||
<mat-error *ngIf="selectedBranchForm.get('name')?.hasError('required')">
|
||||
Name is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="selectedBranchForm.get('name')?.hasError('minlength')">
|
||||
Minimum 3 characters required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Location -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Location</mat-label>
|
||||
<input matInput [formControlName]="'location'" placeholder="e.g., Mumbai" />
|
||||
<mat-error *ngIf="selectedBranchForm.get('location')?.hasError('required')">
|
||||
Location is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Contact -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Contact Number</mat-label>
|
||||
<input matInput type="tel" [formControlName]="'contact'" placeholder="9876543210" />
|
||||
<mat-error *ngIf="selectedBranchForm.get('contact')?.hasError('required')">
|
||||
Contact is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="selectedBranchForm.get('contact')?.hasError('pattern')">
|
||||
Enter valid 10-digit number
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Address - Full Width -->
|
||||
<mat-form-field class="w-full md:col-span-2" appearance="outline">
|
||||
<mat-label>Address</mat-label>
|
||||
<textarea matInput [formControlName]="'address'" rows="3"
|
||||
placeholder="Enter complete branch address"></textarea>
|
||||
<mat-error *ngIf="selectedBranchForm.get('address')?.hasError('required')">
|
||||
Address is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="selectedBranchForm.get('address')?.hasError('minlength')">
|
||||
Minimum 10 characters required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- ⭐ NEW: Created By Field (Read-only) -->
|
||||
@if (branch.id && branch.created_by_username) {
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Created By</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="branch.created_by_username"
|
||||
readonly
|
||||
/>
|
||||
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
@if (branch.id) {
|
||||
<div class="mt-6 pt-6 border-t">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Created:</span>
|
||||
<span class="ml-2 text-gray-900">{{ branch.created_at | date:'medium' }}</span>
|
||||
</div>
|
||||
@if (branch.updated_at && branch.updated_at !== branch.created_at) {
|
||||
<div>
|
||||
<span class="text-gray-500">Last Updated:</span>
|
||||
<span class="ml-2 text-gray-900">{{ branch.updated_at | date:'medium' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex w-full items-center justify-between border-t bg-gray-50 px-8 py-4">
|
||||
<button mat-button [color]="'warn'" (click)="deleteSelectedBranch()"
|
||||
type="button" [disabled]="!branch.id">
|
||||
Delete
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (flashMessage) {
|
||||
<div class="flex items-center text-sm">
|
||||
@if (flashMessage === 'success') {
|
||||
<mat-icon class="icon-size-5 text-green-600 mr-2"
|
||||
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
|
||||
<span class="text-green-600">{{ branch.id ? 'Updated' : 'Created' }} successfully</span>
|
||||
}
|
||||
@if (flashMessage === 'error') {
|
||||
<mat-icon class="icon-size-5 text-red-600 mr-2"
|
||||
[svgIcon]="'heroicons_outline:x-circle'"></mat-icon>
|
||||
<span class="text-red-600">Error occurred</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button mat-stroked-button (click)="closeDetails()" type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button mat-flat-button [color]="'primary'" (click)="updateSelectedBranch()"
|
||||
type="button" [disabled]="selectedBranchForm.invalid || isLoading">
|
||||
@if (isLoading) {
|
||||
<mat-icon class="icon-size-5 animate-spin mr-2" [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
|
||||
}
|
||||
<span>{{ branch.id ? 'Update' : 'Create' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,336 @@
|
||||
/* Branch grid layout - NOW 7 COLUMNS WITH CREATED_BY */
|
||||
.branch-grid {
|
||||
// Branch ID, Code, Name, Location, Contact, Created By, Actions (7 columns)
|
||||
grid-template-columns: 110px 120px 1fr 150px 130px 150px 120px;
|
||||
|
||||
@media (max-width: 1279px) { // xl breakpoint - hide Created By
|
||||
grid-template-columns: 110px 120px 1fr 150px 130px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) { // lg breakpoint - hide location and contact
|
||||
grid-template-columns: 110px 120px 1fr 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) { // md breakpoint - hide branch ID
|
||||
grid-template-columns: 120px 1fr 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) { // sm breakpoint - stack vertically
|
||||
grid-template-columns: 1fr 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Created By Column Styling */
|
||||
.branch-grid > div:nth-child(6) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.branch-grid > div:nth-child(6) mat-icon {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.branch-grid > div:nth-child(6) span {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .branch-grid > div:nth-child(6) mat-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .branch-grid > div:nth-child(6) span {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Material form field customizations */
|
||||
.fuse-mat-dense {
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.fuse-mat-rounded {
|
||||
.mat-mdc-form-field-flex {
|
||||
border-radius: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.mat-focused .mat-mdc-form-field-flex {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon size utilities */
|
||||
.icon-size-5 {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon-size-16 {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
/* Minimal button styles */
|
||||
mat-icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
mat-icon {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover mat-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Form field minimal styling */
|
||||
mat-form-field {
|
||||
&.mat-mdc-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Branch row hover effect */
|
||||
.branch-grid > div:not(.sticky):not(.bg-blue-50) {
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Action buttons minimal styling */
|
||||
.mat-mdc-icon-button {
|
||||
&.mat-primary {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.mat-warn {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
/* Clean header styling */
|
||||
.branch-grid.sticky {
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state styling */
|
||||
.empty-state {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.branch-grid {
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
mat-form-field {
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.fuse-mat-rounded .mat-mdc-form-field-flex {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.fuse-mat-rounded.mat-focused .mat-mdc-form-field-flex {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.branch-grid > div:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar - minimal */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* Focus states for accessibility */
|
||||
.mat-mdc-form-field.mat-focused .mat-mdc-form-field-flex {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Clean transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Pagination styling - minimal */
|
||||
mat-paginator {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Branch ID badge styling */
|
||||
.font-mono {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Clean form appearance */
|
||||
.mat-mdc-form-field-appearance-outline {
|
||||
.mat-mdc-form-field-outline {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&.mat-focused .mat-mdc-form-field-outline {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Minimal hint text */
|
||||
.mat-mdc-form-field-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Error state - clean */
|
||||
.mat-mdc-form-field-error {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Button height consistency */
|
||||
.mat-mdc-raised-button,
|
||||
.mat-mdc-outlined-button {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
/* Clean dividers */
|
||||
.border-b {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Typography - clean hierarchy */
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Compact spacing */
|
||||
.compact-spacing {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Minimal shadow on details panel */
|
||||
.overflow-hidden {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Clean action button group */
|
||||
.flex.gap-1 {
|
||||
button {
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth hover transitions */
|
||||
.transition-colors {
|
||||
transition-property: background-color, color;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
/* Clean badge for branch ID */
|
||||
.text-blue-600 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Minimal progress bar */
|
||||
mat-progress-bar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Clean tooltip */
|
||||
.mat-mdc-tooltip {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* Responsive typography */
|
||||
@media (max-width: 640px) {
|
||||
.text-4xl {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Clean focus ring */
|
||||
button:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Minimal snackbar */
|
||||
.snackbar-success {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.snackbar-error {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
@ -1,12 +1,325 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSortModule, MatSort } from '@angular/material/sort';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, map, switchMap, takeUntil } from 'rxjs';
|
||||
import { BranchService, Branch } from './branch.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-branch-list',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
MatProgressBarModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
templateUrl: './branch-list.component.html',
|
||||
styleUrl: './branch-list.component.scss'
|
||||
})
|
||||
export class BranchListComponent {
|
||||
export class BranchListComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
|
||||
}
|
||||
// Observables
|
||||
branches$: Observable<Branch[]>;
|
||||
private branchesSubject = new BehaviorSubject<Branch[]>([]);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Form controls
|
||||
searchInputControl = new FormControl('');
|
||||
selectedBranchForm!: FormGroup;
|
||||
|
||||
// State management
|
||||
selectedBranch: Branch | null = null;
|
||||
isLoading = false;
|
||||
flashMessage: 'success' | 'error' | null = null;
|
||||
|
||||
// Pagination
|
||||
pagination = {
|
||||
length: 0,
|
||||
page: 0,
|
||||
size: 10
|
||||
};
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private branchService: BranchService,
|
||||
private dialog: MatDialog,
|
||||
private snackBar: MatSnackBar
|
||||
) {
|
||||
this.branches$ = this.branchesSubject.asObservable();
|
||||
this.initializeForm();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBranches();
|
||||
this.setupSearch();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the branch form
|
||||
*/
|
||||
private initializeForm(): void {
|
||||
this.selectedBranchForm = this.fb.group({
|
||||
code: ['', [Validators.required, Validators.pattern(/^[A-Z0-9]+$/)]],
|
||||
name: ['', [Validators.required, Validators.minLength(3)]],
|
||||
location: ['', [Validators.required]],
|
||||
address: ['', [Validators.required, Validators.minLength(10)]],
|
||||
contact: ['', [Validators.required, Validators.pattern(/^[0-9]{10}$/)]]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup search functionality with debounce
|
||||
*/
|
||||
private setupSearch(): void {
|
||||
this.searchInputControl.valueChanges
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((searchTerm) => {
|
||||
this.filterBranches(searchTerm || '');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all branches from the backend
|
||||
*/
|
||||
loadBranches(): void {
|
||||
this.isLoading = true;
|
||||
|
||||
this.branchService.getAllBranches()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (branches) => {
|
||||
this.branchesSubject.next(branches);
|
||||
this.pagination.length = branches.length;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading branches:', error);
|
||||
this.showSnackBar('Failed to load branches', 'error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter branches based on search term
|
||||
*/
|
||||
private filterBranches(searchTerm: string): void {
|
||||
this.branchService.getAllBranches()
|
||||
.pipe(
|
||||
map(branches => {
|
||||
if (!searchTerm) {
|
||||
return branches;
|
||||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return branches.filter(branch =>
|
||||
branch.name.toLowerCase().includes(term) ||
|
||||
branch.code.toLowerCase().includes(term) ||
|
||||
branch.location.toLowerCase().includes(term) ||
|
||||
branch.branch_id.toLowerCase().includes(term)
|
||||
);
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe(filtered => {
|
||||
this.branchesSubject.next(filtered);
|
||||
this.pagination.length = filtered.length;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new branch - open form
|
||||
*/
|
||||
createBranch(): void {
|
||||
this.selectedBranch = {
|
||||
id: 0,
|
||||
branch_id: '',
|
||||
code: '',
|
||||
name: '',
|
||||
location: '',
|
||||
address: '',
|
||||
contact: '',
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
};
|
||||
|
||||
this.selectedBranchForm.reset();
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit existing branch
|
||||
*/
|
||||
editBranch(branch: Branch): void {
|
||||
this.selectedBranch = { ...branch };
|
||||
this.selectedBranchForm.patchValue({
|
||||
code: branch.code,
|
||||
name: branch.name,
|
||||
location: branch.location,
|
||||
address: branch.address,
|
||||
contact: branch.contact
|
||||
});
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle branch details view
|
||||
*/
|
||||
toggleDetails(branchId: number): void {
|
||||
if (this.selectedBranch?.id === branchId) {
|
||||
this.closeDetails();
|
||||
} else {
|
||||
const branch = this.branchesSubject.value.find(b => b.id === branchId);
|
||||
if (branch) {
|
||||
this.editBranch(branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close details panel
|
||||
*/
|
||||
closeDetails(): void {
|
||||
this.selectedBranch = null;
|
||||
this.selectedBranchForm.reset();
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create branch
|
||||
*/
|
||||
updateSelectedBranch(): void {
|
||||
if (this.selectedBranchForm.invalid) {
|
||||
this.selectedBranchForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
const formData = this.selectedBranchForm.value;
|
||||
|
||||
const operation = this.selectedBranch?.id
|
||||
? this.branchService.updateBranch(this.selectedBranch.id, formData)
|
||||
: this.branchService.createBranch(formData);
|
||||
|
||||
operation
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (branch) => {
|
||||
this.flashMessage = 'success';
|
||||
this.isLoading = false;
|
||||
this.showSnackBar(
|
||||
this.selectedBranch?.id ? 'Branch updated successfully' : 'Branch created successfully',
|
||||
'success'
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
this.closeDetails();
|
||||
this.loadBranches();
|
||||
}, 1500);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error saving branch:', error);
|
||||
this.flashMessage = 'error';
|
||||
this.isLoading = false;
|
||||
this.showSnackBar(
|
||||
error.error?.error || 'Failed to save branch',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected branch
|
||||
*/
|
||||
deleteSelectedBranch(): void {
|
||||
if (!this.selectedBranch?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete branch "${this.selectedBranch.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deleteBranch(this.selectedBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete branch
|
||||
*/
|
||||
deleteBranch(branch: Branch): void {
|
||||
if (!confirm(`Are you sure you want to delete branch "${branch.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
this.branchService.deleteBranch(branch.id)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.showSnackBar('Branch deleted successfully', 'success');
|
||||
this.closeDetails();
|
||||
this.loadBranches();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error deleting branch:', error);
|
||||
this.showSnackBar(
|
||||
error.error?.error || 'Failed to delete branch',
|
||||
'error'
|
||||
);
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track by function for ngFor
|
||||
*/
|
||||
trackByFn(index: number, item: Branch): number {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show snackbar notification
|
||||
*/
|
||||
private showSnackBar(message: string, type: 'success' | 'error'): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 3000,
|
||||
horizontalPosition: 'end',
|
||||
verticalPosition: 'top',
|
||||
panelClass: type === 'success' ? 'snackbar-success' : 'snackbar-error'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { environment } from '../../../../../../environments/environment';
|
||||
|
||||
export interface Branch {
|
||||
id: number;
|
||||
branch_id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
location: string;
|
||||
address: string;
|
||||
contact: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// ⭐ NEW: Created by fields (automatic from backend)
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
}
|
||||
|
||||
export interface BranchCreateRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
location: string;
|
||||
address: string;
|
||||
contact: string;
|
||||
// ⭐ NOTE: created_by is NOT included here
|
||||
// Backend automatically extracts it from JWT token
|
||||
}
|
||||
|
||||
export interface BranchUpdateRequest extends BranchCreateRequest {}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BranchService {
|
||||
private apiUrl = `${environment.apiUrl}/branches`; // Adjust based on your environment setup
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/**
|
||||
* Get authorization headers
|
||||
*/
|
||||
private getHeaders(): HttpHeaders {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
return new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all branches
|
||||
*/
|
||||
getAllBranches(): Observable<Branch[]> {
|
||||
return this.http.get<Branch[]>(this.apiUrl, { headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single branch by ID
|
||||
*/
|
||||
getBranch(id: number): Observable<Branch> {
|
||||
return this.http.get<Branch>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new branch
|
||||
* ⭐ NOTE: Don't send created_by in request
|
||||
* Backend automatically extracts it from JWT token
|
||||
*/
|
||||
createBranch(data: BranchCreateRequest): Observable<Branch> {
|
||||
return this.http.post<Branch>(this.apiUrl, data, { headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing branch
|
||||
* ⭐ NOTE: Don't update created_by
|
||||
* It's set once during creation and shouldn't change
|
||||
*/
|
||||
updateBranch(id: number, data: BranchUpdateRequest): Observable<Branch> {
|
||||
return this.http.put<Branch>(`${this.apiUrl}/${id}`, data, { headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete branch
|
||||
*/
|
||||
deleteBranch(id: number): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search branches by query
|
||||
*/
|
||||
searchBranches(query: string): Observable<Branch[]> {
|
||||
return this.http.get<Branch[]>(`${this.apiUrl}/search?q=${encodeURIComponent(query)}`,
|
||||
{ headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP errors
|
||||
*/
|
||||
private handleError(error: any): Observable<never> {
|
||||
console.error('API Error:', error);
|
||||
|
||||
let errorMessage = 'An error occurred';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Client-side error
|
||||
errorMessage = error.error.message;
|
||||
} else {
|
||||
// Server-side error
|
||||
errorMessage = error.error?.error || error.message || 'Server error';
|
||||
}
|
||||
|
||||
return throwError(() => ({
|
||||
error: errorMessage,
|
||||
status: error.status
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1 +1,335 @@
|
||||
<p>client-list works!</p>
|
||||
<!-- client-list.component.html -->
|
||||
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
|
||||
<!-- Loader -->
|
||||
@if (isLoading) {
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Title -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-4xl font-extrabold tracking-tight">Client Management</div>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
{{ pagination.length }} Clients
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
|
||||
<!-- Search -->
|
||||
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
|
||||
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
|
||||
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
|
||||
[placeholder]="'Search clients or refillers'" />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Refresh button -->
|
||||
<button class="ml-4" mat-stroked-button (click)="refresh()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
|
||||
<span class="ml-2 mr-1">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex flex-auto overflow-hidden">
|
||||
<!-- Clients list -->
|
||||
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
|
||||
@if (clients$ | async; as clients) {
|
||||
@if (clients.length > 0 || selectedClient) {
|
||||
<div class="grid">
|
||||
<!-- Header - 8 COLUMNS -->
|
||||
<div class="client-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-md font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
|
||||
matSort matSortDisableClear>
|
||||
<div class="hidden md:block" [mat-sort-header]="'user_id'">User ID</div>
|
||||
<div [mat-sort-header]="'username'">Client Name</div>
|
||||
<div class="hidden sm:block" [mat-sort-header]="'email'">Email</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'contact'">Contact</div>
|
||||
<div class="hidden lg:block">Status</div>
|
||||
<div class="hidden xl:block" [mat-sort-header]="'machines_count'">Machine ID</div>
|
||||
<div class="hidden xl:block" [mat-sort-header]="'refillers_count'">Refillers</div>
|
||||
<div class="hidden sm:block">Details</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Rows -->
|
||||
@for (client of clients; track trackByFn($index, client)) {
|
||||
<div class="client-grid grid items-center gap-4 border-b px-6 py-3 md:px-8">
|
||||
<!-- User ID -->
|
||||
<div class="hidden truncate md:block">
|
||||
<span class="font-mono text-xs text-gray-600">{{ client.user_id }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Client Name -->
|
||||
<div class="flex items-center">
|
||||
<div class="relative mr-3 flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full border bg-gray-200">
|
||||
@if (client.photo) {
|
||||
<img [src]="getFullFileUrl(client.photo)" [alt]="client.username"
|
||||
class="h-full w-full object-cover">
|
||||
} @else {
|
||||
<span class="text-sm font-semibold uppercase">
|
||||
{{ client.username.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<div class="font-medium">{{ client.username }}</div>
|
||||
@if (client.user_id) {
|
||||
<div class="text-xs text-gray-500">{{ client.user_id }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="hidden truncate sm:block">
|
||||
{{ client.email }}
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="hidden truncate lg:block">
|
||||
{{ client.contact }}
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="hidden lg:block">
|
||||
@if (client.user_status === 'Active') {
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
<span class="mr-1 h-2 w-2 rounded-full bg-green-400"></span>
|
||||
Active
|
||||
</span>
|
||||
}
|
||||
@if (client.user_status === 'Inactive') {
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800">
|
||||
<span class="mr-1 h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
Inactive
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Machine IDs -->
|
||||
<div class="hidden xl:block">
|
||||
@if (client.machines && client.machines.length > 0) {
|
||||
<div class="flex flex-col gap-1">
|
||||
@for (machine of client.machines; track machine.machine_id) {
|
||||
<div class="text-xs">
|
||||
<span class="font-mono font-semibold text-blue-600">{{ machine.machine_id }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="text-gray-400 text-xs">No machines</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Refillers -->
|
||||
<div class="hidden xl:block">
|
||||
@if (client.refillers && client.refillers.length > 0) {
|
||||
<div class="flex flex-col gap-1">
|
||||
@for (refiller of client.refillers; track refiller.id) {
|
||||
<div class="text-xs">
|
||||
<span class="font-medium text-green-700">{{ refiller.username }}</span>
|
||||
@if (refiller.assigned_machines_count > 0) {
|
||||
<span class="text-gray-500"> ({{ refiller.assigned_machines_count }} machines)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="text-gray-400 text-xs">No refillers</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Details button -->
|
||||
<div class="hidden sm:block">
|
||||
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button
|
||||
(click)="toggleDetails(client.id)">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="
|
||||
selectedClient?.id === client.id
|
||||
? 'heroicons_solid:chevron-up'
|
||||
: 'heroicons_solid:chevron-down'
|
||||
"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Details -->
|
||||
@if (selectedClient?.id === client.id) {
|
||||
<div class="grid">
|
||||
<ng-container *ngTemplateOutlet="clientDetailsTemplate; context: { $implicit: client }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
|
||||
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
|
||||
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
|
||||
[showFirstLastButtons]="true"></mat-paginator>
|
||||
} @else {
|
||||
<div class="border-t p-8 text-center text-4xl font-semibold tracking-tight sm:p-16">
|
||||
No clients found!
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Client Details Template -->
|
||||
<ng-template #clientDetailsTemplate let-client>
|
||||
<div class="overflow-hidden shadow-lg">
|
||||
<div class="flex border-b">
|
||||
<div class="flex w-full flex-col p-8">
|
||||
<!-- Client Header -->
|
||||
<div class="flex items-start justify-between mb-6 pb-6 border-b">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Photo -->
|
||||
<div class="relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border-4 bg-gray-200">
|
||||
@if (client.photo) {
|
||||
<img [src]="getFullFileUrl(client.photo)" [alt]="client.username"
|
||||
class="h-full w-full object-cover">
|
||||
} @else {
|
||||
<span class="text-3xl font-bold uppercase">
|
||||
{{ client.username.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Client Info -->
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-gray-900">{{ client.username }}</h3>
|
||||
<p class="text-sm text-gray-600 font-mono">{{ client.user_id }}</p>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ client.email }}</p>
|
||||
<p class="text-sm text-gray-600">{{ client.contact }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company Logo -->
|
||||
@if (client.company_logo) {
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-xs text-gray-600 mb-2">Company Logo</span>
|
||||
<div class="h-16 w-16 rounded-lg border-2 bg-white p-2">
|
||||
<img [src]="getFullFileUrl(client.company_logo)" alt="Company logo"
|
||||
class="h-full w-full object-contain">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Machines Section -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<mat-icon class="text-blue-600" [svgIcon]="'heroicons_outline:cog'"></mat-icon>
|
||||
Machines ({{ client.machines_count }})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@if (client.machines && client.machines.length > 0) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@for (machine of client.machines; track machine.machine_id) {
|
||||
<div class="p-4 bg-gray-50 rounded-lg border hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-gray-900">{{ machine.machine_id }}</div>
|
||||
<div class="text-sm text-gray-600">{{ machine.machine_model }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ machine.machine_type }}</div>
|
||||
</div>
|
||||
<mat-icon class="text-gray-400">devices</mat-icon>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="text-xs text-gray-600">
|
||||
<mat-icon class="icon-size-3 inline" [svgIcon]="'heroicons_outline:map-pin'"></mat-icon>
|
||||
{{ machine.branch_name }}
|
||||
</div>
|
||||
<span [class]="'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + getStatusClass(machine.operation_status)">
|
||||
{{ machine.operation_status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<span [class]="'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + getStatusClass(machine.connection_status)">
|
||||
<span class="mr-1 h-1.5 w-1.5 rounded-full" [class]="machine.connection_status === 'online' ? 'bg-green-400' : 'bg-gray-400'"></span>
|
||||
{{ machine.connection_status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="text-center py-8 px-4 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<mat-icon class="text-gray-400 mb-2" style="font-size: 48px; width: 48px; height: 48px;">devices_other</mat-icon>
|
||||
<p class="text-sm text-gray-600">No machines assigned yet</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Refillers Section -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<mat-icon class="text-green-600" [svgIcon]="'heroicons_outline:users'"></mat-icon>
|
||||
Assigned Refillers ({{ client.refillers_count }})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@if (client.refillers && client.refillers.length > 0) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (refiller of client.refillers; track refiller.id) {
|
||||
<div class="p-4 bg-green-50 rounded-lg border border-green-200 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-gray-900">{{ refiller.username }}</div>
|
||||
<div class="text-sm text-gray-600">{{ refiller.email }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ refiller.contact }}</div>
|
||||
<div class="text-xs text-gray-500 font-mono">{{ refiller.user_id }}</div>
|
||||
</div>
|
||||
<mat-icon class="text-green-600">person</mat-icon>
|
||||
</div>
|
||||
|
||||
<!-- Refiller's Machines -->
|
||||
@if (refiller.assigned_machines_count > 0) {
|
||||
<div class="mt-3 pt-3 border-t border-green-200">
|
||||
<div class="text-xs font-medium text-gray-700 mb-2">
|
||||
Assigned Machines ({{ refiller.assigned_machines_count }})
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
@for (machine of refiller.assigned_machines; track machine.machine_id) {
|
||||
<div class="flex items-center justify-between text-xs bg-white px-2 py-1.5 rounded">
|
||||
<span class="font-mono font-medium">{{ machine.machine_id }}</span>
|
||||
<span class="text-gray-500">{{ machine.machine_model }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-3 pt-3 border-t border-green-200 text-xs text-gray-500">
|
||||
No machines assigned
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="text-center py-8 px-4 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<mat-icon class="text-gray-400 mb-2" style="font-size: 48px; width: 48px; height: 48px;">group</mat-icon>
|
||||
<p class="text-sm text-gray-600">No refillers assigned yet</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-end mt-6 pt-6 border-t">
|
||||
<button mat-stroked-button (click)="closeDetails()">
|
||||
<mat-icon>close</mat-icon>
|
||||
<span class="ml-1">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,428 @@
|
||||
/* client-list.component.scss */
|
||||
|
||||
/* Client grid layout - 8 COLUMNS: User ID, Name, Email, Contact, Status, Machine ID, Refillers, Details */
|
||||
.client-grid {
|
||||
// Default: All 8 columns visible on XL screens
|
||||
grid-template-columns: 140px 2fr 2fr 1.5fr 1fr 1.5fr 1.5fr 80px;
|
||||
|
||||
// Ensure all items are vertically centered
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) { // xl breakpoint - hide Machines and Refillers
|
||||
grid-template-columns: 140px 2fr 2fr 1.5fr 1fr 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) { // lg breakpoint - hide Status and Contact
|
||||
grid-template-columns: 140px 2fr 2fr 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) { // md breakpoint - hide User ID
|
||||
grid-template-columns: 2fr 2fr 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) { // sm breakpoint - hide Email
|
||||
grid-template-columns: 1fr 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Machine and Refiller columns - already centered by parent grid item */
|
||||
.client-grid > div:nth-child(6),
|
||||
.client-grid > div:nth-child(7) {
|
||||
/* Machine ID in monospace */
|
||||
.font-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Refiller names in medium weight */
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* For multiple items, use flex-col but still centered */
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Material form field customizations */
|
||||
.fuse-mat-dense {
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.fuse-mat-rounded {
|
||||
.mat-mdc-form-field-flex {
|
||||
border-radius: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.mat-focused .mat-mdc-form-field-flex {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon size utilities */
|
||||
.icon-size-5 {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon-size-4 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.icon-size-3 {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Avatar styles */
|
||||
.client-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Status badge styles */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.active,
|
||||
&.online {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&.inactive,
|
||||
&.offline {
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
&.maintenance {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
/* Machine card styles */
|
||||
.machine-card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Refiller card styles */
|
||||
.refiller-card {
|
||||
background: #f0fdf4;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid #bbf7d0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border-color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state styles */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed #d1d5db;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Details panel */
|
||||
.details-panel {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Section headers in details */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.machine-card {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #f9fafb;
|
||||
|
||||
&:hover {
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
.refiller-card {
|
||||
background: #064e3b;
|
||||
border-color: #059669;
|
||||
color: #f0fdf4;
|
||||
|
||||
&:hover {
|
||||
border-color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background-color: #1f2937;
|
||||
border-color: #4b5563;
|
||||
|
||||
p {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
border-bottom-color: #374151;
|
||||
|
||||
h4 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
.fuse-mat-rounded .mat-mdc-form-field-flex {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.fuse-mat-rounded.mat-focused .mat-mdc-form-field-flex {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.client-grid {
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.machine-card,
|
||||
.refiller-card {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.machine-card,
|
||||
.refiller-card {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Badge animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge span {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
.client-grid > div {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.client-grid > div:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Focus states for accessibility */
|
||||
.mat-mdc-button:focus,
|
||||
.mat-mdc-raised-button:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.status-badge {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.machine-card,
|
||||
.refiller-card {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.mat-mdc-button,
|
||||
.mat-paginator,
|
||||
.search-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
box-shadow: none;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading-overlay {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&.loading::after {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
/* Count badge special styles */
|
||||
.count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid responsiveness helper */
|
||||
@media (max-width: 1279px) {
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.grid-cols-2,
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,294 @@
|
||||
import { Component } from '@angular/core';
|
||||
// client-list.component.ts
|
||||
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, AfterViewInit } from '@angular/core';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortModule, Sort } from '@angular/material/sort';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Observable, Subject, BehaviorSubject } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, takeUntil, startWith } from 'rxjs/operators';
|
||||
import { ClientListService, ClientDetail, ClientMachine, ClientRefiller } from './client-list.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-client-list',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatProgressBarModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
MatExpansionModule
|
||||
],
|
||||
templateUrl: './client-list.component.html',
|
||||
styleUrl: './client-list.component.scss'
|
||||
})
|
||||
export class ClientListComponent {
|
||||
export class ClientListComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
|
||||
}
|
||||
// Observables
|
||||
clients$!: Observable<ClientDetail[]>;
|
||||
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||
|
||||
// Data
|
||||
dataSource = new MatTableDataSource<ClientDetail>();
|
||||
private _clients: BehaviorSubject<ClientDetail[]> = new BehaviorSubject<ClientDetail[]>([]);
|
||||
|
||||
// UI State
|
||||
isLoading: boolean = false;
|
||||
selectedClient: ClientDetail | null = null;
|
||||
|
||||
// Search
|
||||
searchInputControl: FormControl = new FormControl('');
|
||||
|
||||
// Pagination
|
||||
pagination = {
|
||||
length: 0,
|
||||
page: 0,
|
||||
size: 10
|
||||
};
|
||||
|
||||
constructor(
|
||||
private _clientListService: ClientListService,
|
||||
private _changeDetectorRef: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clients$ = this._clients.asObservable();
|
||||
|
||||
// Setup search
|
||||
this.searchInputControl.valueChanges
|
||||
.pipe(
|
||||
takeUntil(this._unsubscribeAll),
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
startWith('')
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.filterClients();
|
||||
});
|
||||
|
||||
// Load clients
|
||||
this.loadClients();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.paginator) {
|
||||
this.paginator.page
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe((page: PageEvent) => {
|
||||
this.pagination.page = page.pageIndex;
|
||||
this.pagination.size = page.pageSize;
|
||||
this.filterClients();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.sort) {
|
||||
this.sort.sortChange
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe((sort: Sort) => {
|
||||
this.sortClients(sort);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._unsubscribeAll.next(null);
|
||||
this._unsubscribeAll.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all clients with their details
|
||||
*/
|
||||
loadClients(): void {
|
||||
this.isLoading = true;
|
||||
|
||||
this._clientListService.getClientsWithDetails()
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe(
|
||||
(clients: ClientDetail[]) => {
|
||||
console.log('Loaded clients:', clients);
|
||||
this._clients.next(clients);
|
||||
this.pagination.length = clients.length;
|
||||
this.isLoading = false;
|
||||
this.filterClients();
|
||||
this._changeDetectorRef.markForCheck();
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error loading clients:', error);
|
||||
this.isLoading = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter clients based on search
|
||||
*/
|
||||
filterClients(): void {
|
||||
let filteredClients = this._clients.getValue();
|
||||
const searchTerm = this.searchInputControl.value?.toLowerCase() || '';
|
||||
|
||||
if (searchTerm) {
|
||||
filteredClients = filteredClients.filter(client =>
|
||||
client.username.toLowerCase().includes(searchTerm) ||
|
||||
client.email.toLowerCase().includes(searchTerm) ||
|
||||
client.contact.includes(searchTerm) ||
|
||||
(client.user_id && client.user_id.toLowerCase().includes(searchTerm)) ||
|
||||
client.refillers.some(r =>
|
||||
r.username.toLowerCase().includes(searchTerm) ||
|
||||
r.email.toLowerCase().includes(searchTerm)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.pagination.length = filteredClients.length;
|
||||
|
||||
const start = this.pagination.page * this.pagination.size;
|
||||
const end = start + this.pagination.size;
|
||||
filteredClients = filteredClients.slice(start, end);
|
||||
|
||||
this.dataSource.data = filteredClients;
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort clients
|
||||
*/
|
||||
sortClients(sort: Sort): void {
|
||||
const clients = this._clients.getValue().slice();
|
||||
|
||||
if (!sort.active || sort.direction === '') {
|
||||
this._clients.next(clients);
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedClients = clients.sort((a, b) => {
|
||||
const isAsc = sort.direction === 'asc';
|
||||
switch (sort.active) {
|
||||
case 'user_id': return this.compare(a.user_id, b.user_id, isAsc);
|
||||
case 'username': return this.compare(a.username, b.username, isAsc);
|
||||
case 'email': return this.compare(a.email, b.email, isAsc);
|
||||
case 'contact': return this.compare(a.contact, b.contact, isAsc);
|
||||
case 'machines_count': return this.compare(a.machines_count, b.machines_count, isAsc);
|
||||
case 'refillers_count': return this.compare(a.refillers_count, b.refillers_count, isAsc);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
this._clients.next(sortedClients);
|
||||
this.filterClients();
|
||||
}
|
||||
|
||||
private compare(a: string | number | undefined, b: string | number | undefined, isAsc: boolean): number {
|
||||
return (a! < b! ? -1 : 1) * (isAsc ? 1 : -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle client details
|
||||
*/
|
||||
toggleDetails(clientId?: number): void {
|
||||
if (this.selectedClient && this.selectedClient.id === clientId) {
|
||||
this.closeDetails();
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this._clients.getValue().find(c => c.id === clientId);
|
||||
if (client) {
|
||||
this.selectedClient = { ...client };
|
||||
|
||||
// Load fresh data for machines and refillers
|
||||
this.loadClientDetails(client.id);
|
||||
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load detailed information for a client
|
||||
*/
|
||||
private loadClientDetails(clientId: number): void {
|
||||
// Load all machines and filter by client_id
|
||||
this._clientListService.getClientsWithDetails()
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe(
|
||||
(clients) => {
|
||||
const clientData = clients.find(c => c.id === clientId);
|
||||
if (clientData && this.selectedClient && this.selectedClient.id === clientId) {
|
||||
// Update machines
|
||||
this.selectedClient.machines = clientData.machines;
|
||||
this.selectedClient.machines_count = clientData.machines_count;
|
||||
|
||||
// Update refillers
|
||||
this.selectedClient.refillers = clientData.refillers;
|
||||
this.selectedClient.refillers_count = clientData.refillers_count;
|
||||
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error loading client details:', error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close details panel
|
||||
*/
|
||||
closeDetails(): void {
|
||||
this.selectedClient = null;
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full file URL
|
||||
*/
|
||||
getFullFileUrl(path: string | undefined): string | null {
|
||||
return this._clientListService.getFullFileUrl(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track by function for ngFor
|
||||
*/
|
||||
trackByFn(index: number, item: any): any {
|
||||
return item.id || index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge class
|
||||
*/
|
||||
getStatusClass(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
case 'online':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'inactive':
|
||||
case 'offline':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'maintenance':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh data
|
||||
*/
|
||||
refresh(): void {
|
||||
this.loadClients();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
// client-list.service.ts
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { environment } from '@environments/environment';
|
||||
|
||||
export interface ClientMachine {
|
||||
machine_id: string;
|
||||
machine_model: string;
|
||||
machine_type: string;
|
||||
branch_name: string;
|
||||
operation_status: string;
|
||||
connection_status: string;
|
||||
}
|
||||
|
||||
export interface ClientRefiller {
|
||||
id: number;
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
contact: string;
|
||||
assigned_machines_count: number;
|
||||
assigned_machines: Array<{
|
||||
machine_id: string;
|
||||
machine_model: string;
|
||||
branch_name: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClientDetail {
|
||||
id: number;
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
contact: string;
|
||||
photo?: string;
|
||||
company_logo?: string;
|
||||
created_at: string;
|
||||
user_status: string;
|
||||
|
||||
// Machines owned by this client
|
||||
machines: ClientMachine[];
|
||||
machines_count: number;
|
||||
|
||||
// Refillers assigned to this client
|
||||
refillers: ClientRefiller[];
|
||||
refillers_count: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ClientListService {
|
||||
private apiUrl = environment.apiUrl;
|
||||
private backendBaseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/**
|
||||
* Get all clients with their machines and refillers
|
||||
*/
|
||||
getClientsWithDetails(): Observable<ClientDetail[]> {
|
||||
// First get all users
|
||||
return this.http.get<any[]>(`${this.apiUrl}/users`).pipe(
|
||||
map(users => {
|
||||
// Filter only clients
|
||||
const clients = users.filter(u => u.roles === 'Client');
|
||||
|
||||
// Also get all machines to map to clients
|
||||
return { clients, users };
|
||||
}),
|
||||
// Now fetch machines
|
||||
switchMap(({ clients, users }) => {
|
||||
return this.http.get<any[]>(`${this.apiUrl}/machines`).pipe(
|
||||
map(machines => {
|
||||
// Transform clients with their machines and refillers
|
||||
return clients.map(client =>
|
||||
this.transformToClientDetail(client, users, machines)
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single client with details
|
||||
*/
|
||||
getClientById(clientId: number): Observable<ClientDetail> {
|
||||
// Fetch client, users, and machines in parallel
|
||||
return this.http.get<any>(`${this.apiUrl}/users/${clientId}`).pipe(
|
||||
switchMap(client => {
|
||||
// Get all users and machines
|
||||
return this.http.get<any[]>(`${this.apiUrl}/users`).pipe(
|
||||
switchMap(users => {
|
||||
return this.http.get<any[]>(`${this.apiUrl}/machines`).pipe(
|
||||
map(machines => this.transformToClientDetail(client, users, machines))
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get machines for a specific client
|
||||
*/
|
||||
getClientMachines(clientId: number): Observable<ClientMachine[]> {
|
||||
return this.http.get<ClientMachine[]>(`${this.apiUrl}/clients/${clientId}/machines`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refillers assigned to a specific client
|
||||
*/
|
||||
getClientRefillers(clientId: number): Observable<ClientRefiller[]> {
|
||||
return this.http.get<ClientRefiller[]>(`${this.apiUrl}/users/${clientId}/assigned-refillers`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform user data to ClientDetail format
|
||||
*/
|
||||
private transformToClientDetail(client: any, allUsers: any[], allMachines: any[]): ClientDetail {
|
||||
// Get machines owned by this client (client_id matches)
|
||||
const machines: ClientMachine[] = allMachines
|
||||
.filter(machine => machine.client_id === client.id)
|
||||
.map(machine => ({
|
||||
machine_id: machine.machine_id,
|
||||
machine_model: machine.machine_model,
|
||||
machine_type: machine.machine_type,
|
||||
branch_name: machine.branch_name,
|
||||
operation_status: machine.operation_status,
|
||||
connection_status: machine.connection_status
|
||||
}));
|
||||
|
||||
// Get refillers assigned to this client
|
||||
const refillers = allUsers
|
||||
.filter(u => u.roles === 'Refiller' && u.assigned_to === client.id)
|
||||
.map(refiller => ({
|
||||
id: refiller.id,
|
||||
user_id: refiller.user_id,
|
||||
username: refiller.username,
|
||||
email: refiller.email,
|
||||
contact: refiller.contact,
|
||||
assigned_machines_count: refiller.assigned_machines_count || 0,
|
||||
assigned_machines: refiller.assigned_machines || []
|
||||
}));
|
||||
|
||||
return {
|
||||
id: client.id,
|
||||
user_id: client.user_id,
|
||||
username: client.username,
|
||||
email: client.email,
|
||||
contact: client.contact,
|
||||
photo: client.photo,
|
||||
company_logo: client.company_logo,
|
||||
created_at: client.created_at,
|
||||
user_status: client.user_status,
|
||||
machines: machines,
|
||||
machines_count: machines.length,
|
||||
refillers: refillers,
|
||||
refillers_count: refillers.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full file URL (same as UserService)
|
||||
*/
|
||||
getFullFileUrl(path: string | undefined | null): string | null {
|
||||
if (!path) return null;
|
||||
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (path.startsWith('/')) {
|
||||
return this.backendBaseUrl + path;
|
||||
}
|
||||
|
||||
return this.backendBaseUrl + '/' + path;
|
||||
}
|
||||
}
|
||||
@ -1 +1,268 @@
|
||||
<p>company-admin-list works!</p>
|
||||
<!-- company-admin-list.component.html -->
|
||||
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
|
||||
<!-- Loader -->
|
||||
@if (isLoading) {
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Title -->
|
||||
<div class="text-4xl font-extrabold tracking-tight">Company Admin Management</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
|
||||
<!-- Search -->
|
||||
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
|
||||
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
|
||||
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
|
||||
[placeholder]="'Search admins'" />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Refresh button -->
|
||||
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="refreshAdmins()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
|
||||
<span class="ml-2 mr-1">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex flex-auto overflow-hidden">
|
||||
<!-- Admin list -->
|
||||
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
|
||||
@if (admins$ | async; as admins) {
|
||||
@if (filteredAdmins.length > 0 || searchInputControl.value) {
|
||||
<div class="grid">
|
||||
<!-- Header - 7 COLUMNS: User ID, Name, Email, Contact, Role, Status, Details -->
|
||||
<div class="admin-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-md font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
|
||||
matSort matSortDisableClear (matSortChange)="onSortChange($event)">
|
||||
<div class="hidden md:block" [mat-sort-header]="'user_id'">User ID</div>
|
||||
<div [mat-sort-header]="'username'">Admin Name</div>
|
||||
<div class="hidden sm:block" [mat-sort-header]="'email'">Email</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'contact'">Contact</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'roles'">Role</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'user_status'">Status</div>
|
||||
<div class="hidden sm:block">Details</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Rows -->
|
||||
@for (admin of filteredAdmins; track trackByFn($index, admin)) {
|
||||
<div class="admin-grid grid items-center gap-4 border-b px-6 py-3 md:px-8">
|
||||
<!-- User ID -->
|
||||
<div class="hidden truncate md:block">
|
||||
<span class="font-mono text-xs text-gray-600">{{ admin.user_id }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Admin Name with Avatar -->
|
||||
<div class="flex items-center">
|
||||
<div class="relative mr-3 flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full border bg-gray-200">
|
||||
@if (admin.photo) {
|
||||
<img [src]="getFullFileUrl(admin.photo)" [alt]="admin.username"
|
||||
class="h-full w-full object-cover">
|
||||
} @else {
|
||||
<span class="text-sm font-semibold uppercase">
|
||||
{{ admin.username.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<div class="font-medium">{{ admin.username }}</div>
|
||||
@if (admin.created_by_username) {
|
||||
<div class="text-xs text-gray-500 flex items-center gap-1">
|
||||
<mat-icon class="icon-size-3">person_add</mat-icon>
|
||||
<span>by {{ admin.created_by_username }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="hidden truncate sm:block">
|
||||
{{ admin.email }}
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="hidden truncate lg:block">
|
||||
{{ admin.contact }}
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div class="hidden lg:block">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" [ngClass]="{
|
||||
'bg-orange-100 text-orange-800': admin.roles === 'SuperAdmin',
|
||||
'bg-amber-100 text-amber-800': admin.roles === 'Management',
|
||||
'bg-red-100 text-red-800': admin.roles === 'Admin'
|
||||
}">
|
||||
{{ admin.roles }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="hidden lg:block">
|
||||
@if (admin.user_status === 'Active') {
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
<span class="mr-1 h-2 w-2 rounded-full bg-green-400 animate-pulse"></span>
|
||||
Active
|
||||
</span>
|
||||
}
|
||||
@if (admin.user_status === 'Inactive') {
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800">
|
||||
<span class="mr-1 h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
Inactive
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Details button -->
|
||||
<div class="hidden sm:block">
|
||||
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button
|
||||
(click)="toggleDetails(admin.id)">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="
|
||||
selectedAdmin?.id === admin.id
|
||||
? 'heroicons_solid:chevron-up'
|
||||
: 'heroicons_solid:chevron-down'
|
||||
"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Details Panel -->
|
||||
@if (selectedAdmin?.id === admin.id) {
|
||||
<div class="grid bg-gray-50 dark:bg-gray-900">
|
||||
<div class="border-b p-8">
|
||||
<!-- Admin Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border-4 border-white shadow-lg bg-gray-200">
|
||||
@if (selectedAdmin.photo) {
|
||||
<img [src]="getFullFileUrl(selectedAdmin.photo)" [alt]="selectedAdmin.username"
|
||||
class="h-full w-full object-cover">
|
||||
} @else {
|
||||
<span class="text-3xl font-bold uppercase">
|
||||
{{ selectedAdmin.username.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold">{{ selectedAdmin.username }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ selectedAdmin.user_id }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" [ngClass]="{
|
||||
'bg-orange-100 text-orange-800': selectedAdmin.roles === 'SuperAdmin',
|
||||
'bg-amber-100 text-amber-800': selectedAdmin.roles === 'Management',
|
||||
'bg-red-100 text-red-800': selectedAdmin.roles === 'Admin'
|
||||
}">
|
||||
{{ selectedAdmin.roles }}
|
||||
</span>
|
||||
@if (selectedAdmin.user_status === 'Active') {
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
<span class="mr-1 h-2 w-2 rounded-full bg-green-400 animate-pulse"></span>
|
||||
Active
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (selectedAdmin.company_logo) {
|
||||
<div class="flex items-center">
|
||||
<img [src]="getFullFileUrl(selectedAdmin.company_logo)" alt="Company logo"
|
||||
class="h-16 w-auto object-contain">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Admin Information Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Contact Information -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<mat-icon class="icon-size-5 text-blue-600">contact_mail</mat-icon>
|
||||
Contact Information
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-icon class="icon-size-4 text-gray-500">email</mat-icon>
|
||||
<span class="text-sm">{{ selectedAdmin.email }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-icon class="icon-size-4 text-gray-500">phone</mat-icon>
|
||||
<span class="text-sm">{{ selectedAdmin.contact }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Created By -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<mat-icon class="icon-size-5 text-green-600">person_add</mat-icon>
|
||||
Created By
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-icon class="icon-size-4 text-gray-500">account_circle</mat-icon>
|
||||
<span class="text-sm font-medium">{{ selectedAdmin.created_by_username || 'System' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-icon class="icon-size-4 text-gray-500">calendar_today</mat-icon>
|
||||
<span class="text-sm">{{ selectedAdmin.created_at | date:'short' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Status -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<mat-icon class="icon-size-5 text-purple-600">info</mat-icon>
|
||||
Account Status
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-icon class="icon-size-4 text-gray-500">verified_user</mat-icon>
|
||||
<span class="text-sm">{{ selectedAdmin.user_status }}</span>
|
||||
</div>
|
||||
@if (selectedAdmin.updated_at) {
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-icon class="icon-size-4 text-gray-500">update</mat-icon>
|
||||
<span class="text-sm">Updated: {{ selectedAdmin.updated_at | date:'short' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button mat-flat-button [color]="'primary'" (click)="closeDetails()">
|
||||
<mat-icon>close</mat-icon>
|
||||
<span class="ml-2">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<mat-paginator
|
||||
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
|
||||
[ngClass]="{ 'pointer-events-none': isLoading }"
|
||||
[length]="pagination.length"
|
||||
[pageIndex]="pagination.page"
|
||||
[pageSize]="pagination.size"
|
||||
[pageSizeOptions]="pagination.pageSizeOptions"
|
||||
[showFirstLastButtons]="true"
|
||||
(page)="onPageChange($event)">
|
||||
</mat-paginator>
|
||||
} @else {
|
||||
<div class="border-t p-8 text-center text-4xl font-semibold tracking-tight sm:p-16">
|
||||
No admin users found!
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,237 @@
|
||||
/* company-admin-list.component.scss */
|
||||
|
||||
/* Admin grid layout - 7 COLUMNS: User ID, Name, Email, Contact, Role, Status, Details */
|
||||
.admin-grid {
|
||||
// Default: All 7 columns visible on XL screens
|
||||
grid-template-columns: 140px 2fr 2fr 1.5fr 1.2fr 1fr 80px;
|
||||
|
||||
// Ensure all items are vertically centered
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) { // lg breakpoint - hide Role and Status
|
||||
grid-template-columns: 140px 2fr 2fr 1.5fr 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) { // md breakpoint - hide User ID
|
||||
grid-template-columns: 2fr 2fr 1.5fr 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) { // sm breakpoint - hide Email
|
||||
grid-template-columns: 1fr 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover effect for rows */
|
||||
.admin-grid:not(.sticky) {
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode hover */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.admin-grid:not(.sticky):hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Avatar styling */
|
||||
.admin-grid img {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.admin-grid:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Status badge animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Details panel animation */
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-grid + .grid {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Role badge colors */
|
||||
.bg-orange-100 {
|
||||
background-color: #ffedd5;
|
||||
}
|
||||
|
||||
.text-orange-800 {
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.bg-amber-100 {
|
||||
background-color: #fef3c7;
|
||||
}
|
||||
|
||||
.text-amber-800 {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.bg-red-100 {
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.text-red-800 {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Status badge colors */
|
||||
.bg-green-100 {
|
||||
background-color: #dcfce7;
|
||||
}
|
||||
|
||||
.text-green-800 {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.bg-green-400 {
|
||||
background-color: #4ade80;
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.text-gray-800 {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.bg-gray-400 {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Details panel card styling */
|
||||
.bg-white {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Responsive text sizing */
|
||||
@media (max-width: 639px) {
|
||||
.text-4xl {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Icon sizing */
|
||||
.icon-size-3 {
|
||||
height: 0.75rem;
|
||||
width: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.icon-size-4 {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.icon-size-5 {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.sm\:overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sm\:overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sm\:overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sm\:overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sm\:overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.sm\:overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.admin-grid {
|
||||
grid-template-columns: 100px 1fr 1fr 120px 100px 80px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
button, mat-paginator {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.admin-grid:hover {
|
||||
outline: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.bg-green-100,
|
||||
.bg-gray-100,
|
||||
.bg-orange-100,
|
||||
.bg-amber-100,
|
||||
.bg-red-100 {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,246 @@
|
||||
import { Component } from '@angular/core';
|
||||
// company-admin-list.component.ts
|
||||
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormsModule, FormControl } from '@angular/forms';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSortModule, MatSort, Sort } from '@angular/material/sort';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Observable, Subject, BehaviorSubject } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, map, startWith, takeUntil } from 'rxjs/operators';
|
||||
import { CompanyAdminListService, AdminDetail } from './company-admin-list.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-company-admin-list',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatProgressBarModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
templateUrl: './company-admin-list.component.html',
|
||||
styleUrl: './company-admin-list.component.scss'
|
||||
})
|
||||
export class CompanyAdminListComponent {
|
||||
export class CompanyAdminListComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
|
||||
}
|
||||
// Observables
|
||||
admins$!: Observable<AdminDetail[]>;
|
||||
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||
|
||||
// Data
|
||||
private _admins: BehaviorSubject<AdminDetail[]> = new BehaviorSubject<AdminDetail[]>([]);
|
||||
filteredAdmins: AdminDetail[] = [];
|
||||
|
||||
// UI State
|
||||
isLoading: boolean = false;
|
||||
selectedAdmin: AdminDetail | null = null;
|
||||
|
||||
// Search
|
||||
searchInputControl: FormControl = new FormControl('');
|
||||
|
||||
// Pagination
|
||||
pagination = {
|
||||
length: 0,
|
||||
page: 0,
|
||||
size: 10,
|
||||
pageSizeOptions: [5, 10, 25, 100]
|
||||
};
|
||||
|
||||
constructor(
|
||||
private _adminService: CompanyAdminListService,
|
||||
private _changeDetectorRef: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.admins$ = this._admins.asObservable();
|
||||
|
||||
// Search functionality
|
||||
this.searchInputControl.valueChanges
|
||||
.pipe(
|
||||
takeUntil(this._unsubscribeAll),
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
startWith('')
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.filterAdmins();
|
||||
});
|
||||
|
||||
// Load admin users
|
||||
this.loadAdmins();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._unsubscribeAll.next(null);
|
||||
this._unsubscribeAll.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all admin users (Management, SuperAdmin, Admin)
|
||||
*/
|
||||
loadAdmins(): void {
|
||||
this.isLoading = true;
|
||||
|
||||
this._adminService.getAdminsWithDetails()
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe({
|
||||
next: (admins) => {
|
||||
this._admins.next(admins);
|
||||
this.pagination.length = admins.length;
|
||||
this.isLoading = false;
|
||||
this.filterAdmins();
|
||||
this._changeDetectorRef.markForCheck();
|
||||
|
||||
console.log('Loaded admin users:', admins);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading admin users:', error);
|
||||
this.isLoading = false;
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter admins based on search term
|
||||
*/
|
||||
filterAdmins(): void {
|
||||
let admins = this._admins.getValue();
|
||||
const searchTerm = this.searchInputControl.value?.toLowerCase() || '';
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
admins = admins.filter(admin =>
|
||||
admin.username.toLowerCase().includes(searchTerm) ||
|
||||
admin.email.toLowerCase().includes(searchTerm) ||
|
||||
admin.contact.includes(searchTerm) ||
|
||||
admin.roles.toLowerCase().includes(searchTerm) ||
|
||||
(admin.user_id && admin.user_id.toLowerCase().includes(searchTerm)) ||
|
||||
(admin.created_by_username && admin.created_by_username.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
this.pagination.length = admins.length;
|
||||
|
||||
// Apply pagination
|
||||
const start = this.pagination.page * this.pagination.size;
|
||||
const end = start + this.pagination.size;
|
||||
this.filteredAdmins = admins.slice(start, end);
|
||||
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort admins
|
||||
*/
|
||||
sortAdmins(sort: Sort): void {
|
||||
const admins = this._admins.getValue().slice();
|
||||
|
||||
if (!sort.active || sort.direction === '') {
|
||||
this._admins.next(admins);
|
||||
this.filterAdmins();
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedAdmins = admins.sort((a, b) => {
|
||||
const isAsc = sort.direction === 'asc';
|
||||
switch (sort.active) {
|
||||
case 'user_id': return this.compare(a.user_id, b.user_id, isAsc);
|
||||
case 'username': return this.compare(a.username, b.username, isAsc);
|
||||
case 'email': return this.compare(a.email, b.email, isAsc);
|
||||
case 'contact': return this.compare(a.contact, b.contact, isAsc);
|
||||
case 'roles': return this.compare(a.roles, b.roles, isAsc);
|
||||
case 'user_status': return this.compare(a.user_status, b.user_status, isAsc);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
this._admins.next(sortedAdmins);
|
||||
this.filterAdmins();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare function for sorting
|
||||
*/
|
||||
private compare(a: string | number | undefined, b: string | number | undefined, isAsc: boolean): number {
|
||||
if (a === undefined || a === null) a = '';
|
||||
if (b === undefined || b === null) b = '';
|
||||
return (a < b ? -1 : 1) * (isAsc ? 1 : -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pagination change
|
||||
*/
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pagination.page = event.pageIndex;
|
||||
this.pagination.size = event.pageSize;
|
||||
this.filterAdmins();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sort change
|
||||
*/
|
||||
onSortChange(sort: Sort): void {
|
||||
this.sortAdmins(sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle admin details
|
||||
*/
|
||||
toggleDetails(adminId: number): void {
|
||||
if (this.selectedAdmin && this.selectedAdmin.id === adminId) {
|
||||
this.closeDetails();
|
||||
return;
|
||||
}
|
||||
|
||||
const admin = this._admins.getValue().find(a => a.id === adminId);
|
||||
if (admin) {
|
||||
this.selectedAdmin = { ...admin };
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close details panel
|
||||
*/
|
||||
closeDetails(): void {
|
||||
this.selectedAdmin = null;
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh admin list
|
||||
*/
|
||||
refreshAdmins(): void {
|
||||
this.loadAdmins();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full file URL
|
||||
*/
|
||||
getFullFileUrl(path: string | undefined): string | null {
|
||||
return this._adminService.getFullFileUrl(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track by function for ngFor
|
||||
*/
|
||||
trackByFn(index: number, item: AdminDetail): any {
|
||||
return item.id || index;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
// company-admin-list.service.ts
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { environment } from 'environments/environment';
|
||||
|
||||
export interface AdminDetail {
|
||||
id: number;
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
contact: string;
|
||||
roles: string;
|
||||
photo?: string;
|
||||
company_logo?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
user_status: string;
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
assigned_to?: number;
|
||||
assigned_to_username?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CompanyAdminListService {
|
||||
private apiUrl = environment.apiUrl;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/**
|
||||
* Get all admin users (Management, SuperAdmin, Admin)
|
||||
*/
|
||||
getAdminsWithDetails(): Observable<AdminDetail[]> {
|
||||
return this.http.get<any[]>(`${this.apiUrl}/users`).pipe(
|
||||
map(users => {
|
||||
// Filter only admin roles: Management, SuperAdmin, Admin
|
||||
const adminUsers = users.filter(u =>
|
||||
u.roles === 'Management' ||
|
||||
u.roles === 'SuperAdmin' ||
|
||||
u.roles === 'Admin'
|
||||
);
|
||||
|
||||
// Transform to AdminDetail format
|
||||
return adminUsers.map(admin => this.transformToAdminDetail(admin, users));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single admin by ID
|
||||
*/
|
||||
getAdminById(adminId: number): Observable<AdminDetail> {
|
||||
return this.http.get<any>(`${this.apiUrl}/users/${adminId}`).pipe(
|
||||
switchMap(admin => {
|
||||
return this.http.get<any[]>(`${this.apiUrl}/users`).pipe(
|
||||
map(users => this.transformToAdminDetail(admin, users))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform user data to AdminDetail format
|
||||
*/
|
||||
private transformToAdminDetail(admin: any, allUsers: any[]): AdminDetail {
|
||||
// Find who created this admin
|
||||
let createdByUsername = 'System';
|
||||
if (admin.created_by) {
|
||||
const creator = allUsers.find(u => u.id === admin.created_by);
|
||||
if (creator) {
|
||||
createdByUsername = creator.username;
|
||||
}
|
||||
}
|
||||
|
||||
// Find who this admin is assigned to (if applicable)
|
||||
let assignedToUsername = null;
|
||||
if (admin.assigned_to) {
|
||||
const assignedUser = allUsers.find(u => u.id === admin.assigned_to);
|
||||
if (assignedUser) {
|
||||
assignedToUsername = assignedUser.username;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: admin.id,
|
||||
user_id: admin.user_id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
contact: admin.contact,
|
||||
roles: admin.roles,
|
||||
photo: admin.photo,
|
||||
company_logo: admin.company_logo,
|
||||
created_at: admin.created_at,
|
||||
updated_at: admin.updated_at,
|
||||
user_status: admin.user_status,
|
||||
created_by: admin.created_by,
|
||||
created_by_username: createdByUsername,
|
||||
assigned_to: admin.assigned_to,
|
||||
assigned_to_username: assignedToUsername
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert relative file path to full URL
|
||||
*/
|
||||
getFullFileUrl(path: string | undefined): string | null {
|
||||
if (!path) return null;
|
||||
|
||||
// If already a full URL, return as is
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Remove leading slash if present
|
||||
const cleanPath = path.startsWith('/') ? path.substring(1) : path;
|
||||
|
||||
// Construct full URL
|
||||
return `${this.apiUrl}/${cleanPath}`;
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,15 @@
|
||||
<!-- src/app/machines/machine-details/machine-details.component.html -->
|
||||
<div class="flex-auto p-6 sm:p-10 overflow-auto max-h-[calc(100vh-100px)]">
|
||||
<div class="machine-details-container">
|
||||
<!-- Header Bar -->
|
||||
<div class="header-bar">
|
||||
<button class="back-button" (click)="goBack()">
|
||||
<i class="fa fa-arrow-left"></i> Back
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button class="save-button" (click)="saveChanges()">
|
||||
<i class="fa fa-save"></i> Save Changes
|
||||
<button class="save-button" (click)="saveChanges()" [disabled]="isLoading">
|
||||
<i class="fa fa-save"></i>
|
||||
{{ isLoading ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -17,8 +20,13 @@
|
||||
<button class="retry-button" (click)="loadMachineData()">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div *ngIf="isLoading" class="loading-spinner">
|
||||
<i class="fa fa-spinner fa-spin"></i> Loading...
|
||||
</div>
|
||||
|
||||
<!-- Information section -->
|
||||
<div class="info-card" *ngIf="!errorMessage">
|
||||
<div class="info-card" *ngIf="!errorMessage && !isLoading">
|
||||
<h3>Information</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
@ -61,7 +69,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Vending rows container -->
|
||||
<div class="vending-container" *ngIf="!errorMessage">
|
||||
<div class="vending-container" *ngIf="!errorMessage && !isLoading">
|
||||
<div *ngFor="let row of vendingRows" class="vending-row">
|
||||
<div class="row-title">
|
||||
<span>{{row.name}}</span>
|
||||
@ -73,19 +81,28 @@
|
||||
</div>
|
||||
|
||||
<div class="slots-container">
|
||||
<div *ngFor="let slot of row.slots" class="slot-card" [ngClass]="{'disabled': !slot.enabled}">
|
||||
<div *ngFor="let slot of row.slots"
|
||||
class="slot-card"
|
||||
[ngClass]="{'disabled': !slot.enabled}">
|
||||
|
||||
<!-- Slot Header -->
|
||||
<div class="slot-header">
|
||||
<span class="slot-name">{{slot.name}}</span>
|
||||
<div class="toggle-container">
|
||||
<label class="switch">
|
||||
<input type="checkbox" [checked]="slot.enabled" (click)="toggleSlotStatus(row, slot, $event)">
|
||||
<input type="checkbox"
|
||||
[checked]="slot.enabled"
|
||||
(click)="toggleSlotStatus(row, slot, $event)">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="status-text" [ngClass]="{'enabled': slot.enabled, 'disabled': !slot.enabled}">
|
||||
<span class="status-text"
|
||||
[ngClass]="{'enabled': slot.enabled, 'disabled': !slot.enabled}">
|
||||
{{slot.enabled ? 'Enabled' : 'Disabled'}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot Body -->
|
||||
<div class="slot-body" (click)="openProductSelector(row, slot, $event)">
|
||||
<ng-container *ngIf="!slot.product; else productAssigned">
|
||||
<div class="empty-slot">
|
||||
@ -95,16 +112,27 @@
|
||||
<p class="add-product-text">Assign Product</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #productAssigned>
|
||||
<div class="product-image-container">
|
||||
<img *ngIf="slot.product?.product_image"
|
||||
[src]="'https://iotbackend.rootxwire.com/' + slot.product?.product_image"
|
||||
[attr.alt]="slot.product?.product_name" class="product-image">
|
||||
<!-- Show image if valid -->
|
||||
<img *ngIf="hasValidImage(slot.product?.product_image)"
|
||||
[src]="getImageUrl(slot.product?.product_image)"
|
||||
[attr.alt]="slot.product?.product_name"
|
||||
class="product-image"
|
||||
(error)="onImageError($event, slot.product?.product_image)">
|
||||
|
||||
<!-- Show gallery icon if no valid image -->
|
||||
<div *ngIf="!hasValidImage(slot.product?.product_image)"
|
||||
class="gallery-icon-container">
|
||||
<i class="fa fa-image gallery-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-details">
|
||||
<div class="product-header">
|
||||
<p class="product-name">{{slot.product.product_name}}</p>
|
||||
<button class="remove-product" (click)="removeProductFromSlot(slot, $event)">
|
||||
<p class="product-name">{{slot.product?.product_name}}</p>
|
||||
<button class="remove-product"
|
||||
(click)="removeProductFromSlot(slot, $event)">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ -122,7 +150,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Product Selector Modal -->
|
||||
<div *ngIf="showProductSelector" class="modal-overlay">
|
||||
<div *ngIf="showProductSelector" class="modal-overlay" (click)="closeProductSelector()">
|
||||
<div class="product-selector-modal" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>Select Product for {{selectedSlot?.name}}</h3>
|
||||
@ -132,56 +160,92 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- Search Bar -->
|
||||
<div class="search-container">
|
||||
<input type="text" placeholder="Search products..." class="search-input" [(ngModel)]="searchTerm"
|
||||
(input)="filterProducts()">
|
||||
<input type="text"
|
||||
placeholder="Search products..."
|
||||
class="search-input"
|
||||
[(ngModel)]="searchTerm"
|
||||
(input)="filterProducts()">
|
||||
</div>
|
||||
|
||||
<!-- Products Table -->
|
||||
<div class="products-table">
|
||||
<div *ngFor="let product of filteredProducts; let i = index" class="product-row"
|
||||
[ngClass]="{'selected': product.selected, 'odd': i % 2 === 0}"
|
||||
(click)="onProductSelectionChange(product)">
|
||||
<div *ngFor="let product of filteredProducts; let i = index"
|
||||
class="product-row"
|
||||
[ngClass]="{'selected': product.selected, 'odd': i % 2 === 0}"
|
||||
(click)="onProductSelectionChange(product)">
|
||||
|
||||
<!-- Checkbox -->
|
||||
<div class="checkbox-cell">
|
||||
<input type="checkbox" [checked]="product.selected" [id]="'product-' + product.product_id"
|
||||
(click)="$event.stopPropagation(); onProductSelectionChange(product);">
|
||||
<input type="checkbox"
|
||||
[checked]="product.selected"
|
||||
[id]="'product-' + product.product_id"
|
||||
(click)="$event.stopPropagation(); onProductSelectionChange(product)">
|
||||
</div>
|
||||
|
||||
<!-- Product ID -->
|
||||
<div class="number-cell">{{product.product_id}}</div>
|
||||
|
||||
<!-- Product Name -->
|
||||
<div class="product-name-cell">{{product.product_name}}</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="price-cell">₹{{product.price}}</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="image-cell">
|
||||
<img *ngIf="product.product_image" [src]="'http://localhost:5000/' + product.product_image"
|
||||
alt="{{product.product_name}}" class="product-thumbnail">
|
||||
<!-- Show image if valid -->
|
||||
<img *ngIf="hasValidImage(product.product_image)"
|
||||
[src]="getImageUrl(product.product_image)"
|
||||
[attr.alt]="product.product_name"
|
||||
class="product-thumbnail"
|
||||
(error)="onImageError($event, product.product_image)">
|
||||
|
||||
<!-- Show gallery icon if no valid image -->
|
||||
<div *ngIf="!hasValidImage(product.product_image)"
|
||||
class="gallery-icon-thumbnail">
|
||||
<i class="fa fa-image"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Spinner (only for selected product) -->
|
||||
<div class="quantity-cell">
|
||||
<div class="quantity-spinner" *ngIf="product.selected">
|
||||
<button class="quantity-btn" [disabled]="productQuantity <= 1"
|
||||
(click)="$event.stopPropagation(); decreaseQuantity()">
|
||||
<button class="quantity-btn"
|
||||
[disabled]="productQuantity <= 1"
|
||||
(click)="$event.stopPropagation(); decreaseQuantity()">
|
||||
<i class="fa fa-minus"></i>
|
||||
</button>
|
||||
<input type="number" class="quantity-input" [(ngModel)]="productQuantity" min="1"
|
||||
(click)="$event.stopPropagation()" (change)="setQuantity($event)">
|
||||
<button class="quantity-btn" (click)="$event.stopPropagation(); increaseQuantity()">
|
||||
<input type="number"
|
||||
class="quantity-input"
|
||||
[(ngModel)]="productQuantity"
|
||||
min="1"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="setQuantity($event)">
|
||||
<button class="quantity-btn"
|
||||
(click)="$event.stopPropagation(); increaseQuantity()">
|
||||
<i class="fa fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Products Found -->
|
||||
<div *ngIf="filteredProducts.length === 0" class="no-products">
|
||||
No products found matching "{{searchTerm}}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="modal-footer">
|
||||
<button class="btn-cancel" (click)="closeProductSelector()">Cancel</button>
|
||||
<button class="btn-add" [disabled]="!selectedProduct" (click)="confirmProductSelection()">
|
||||
<button class="btn-cancel" (click)="closeProductSelector()">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-add"
|
||||
[disabled]="!selectedProduct"
|
||||
(click)="confirmProductSelection()">
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -110,6 +110,65 @@ $white: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// src/app/machines/machine-details/machine-details.component.scss
|
||||
|
||||
// Gallery icon container (in slot card)
|
||||
.gallery-icon-container {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
|
||||
.gallery-icon {
|
||||
font-size: 48px;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery icon in product selector modal thumbnail
|
||||
.gallery-icon-thumbnail {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
i {
|
||||
font-size: 24px;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
|
||||
// Existing styles...
|
||||
.product-image-container {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.product-thumbnail {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// Vending container & row
|
||||
.vending-container {
|
||||
display: flex;
|
||||
|
||||
@ -1,10 +1,26 @@
|
||||
// src/app/machines/machine-details/machine-details.component.ts
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { MachineService, Machine } from '../machine.service';
|
||||
import { ProductService } from '../../products/product.service';
|
||||
import { ProductService, Product } from '../../products/product.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { environment } from '@environments/environment';
|
||||
|
||||
interface Slot {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
units: number;
|
||||
price: string | number;
|
||||
product: Product | null;
|
||||
}
|
||||
|
||||
interface VendingRow {
|
||||
name: string;
|
||||
id: string;
|
||||
slots: Slot[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-machine-details',
|
||||
@ -26,7 +42,7 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
connection_status: ''
|
||||
};
|
||||
|
||||
vendingRows = ['A', 'B', 'C', 'D', 'E', 'F'].map(rowId => ({
|
||||
vendingRows: VendingRow[] = ['A', 'B', 'C', 'D', 'E', 'F'].map(rowId => ({
|
||||
name: `Row ${rowId}`,
|
||||
id: rowId,
|
||||
slots: Array.from({ length: 10 }, (_, i) => ({
|
||||
@ -38,16 +54,21 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
}))
|
||||
}));
|
||||
|
||||
products = [];
|
||||
filteredProducts = [];
|
||||
products: Product[] = [];
|
||||
filteredProducts: Product[] = [];
|
||||
searchTerm = '';
|
||||
showProductSelector = false;
|
||||
selectedSlot = null;
|
||||
selectedRow = null;
|
||||
selectedProduct = null;
|
||||
selectedSlot: Slot | null = null;
|
||||
selectedRow: VendingRow | null = null;
|
||||
selectedProduct: Product | null = null;
|
||||
productQuantity = 1;
|
||||
|
||||
private subscriptions: Subscription[] = [];
|
||||
errorMessage: string | null = null; // To display error messages on the UI
|
||||
errorMessage: string | null = null;
|
||||
isLoading = false;
|
||||
|
||||
// Track image loading errors
|
||||
imageLoadErrors = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private machineService: MachineService,
|
||||
@ -65,67 +86,78 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
/**
|
||||
* Load machine data from route or service
|
||||
*/
|
||||
loadMachineData(): void {
|
||||
// First, try to get the machine ID from the route
|
||||
const machineId = this.route.snapshot.paramMap.get('id');
|
||||
|
||||
if (machineId) {
|
||||
this.machineService.getMachineById(Number(machineId)).subscribe(
|
||||
fetchedMachine => {
|
||||
this.isLoading = true;
|
||||
this.machineService.getMachineById(Number(machineId)).subscribe({
|
||||
next: (fetchedMachine) => {
|
||||
if (fetchedMachine?.machine_id) {
|
||||
this.machine = fetchedMachine;
|
||||
this.machineService.setSelectedMachine(fetchedMachine);
|
||||
this.loadVendingState(fetchedMachine.machine_id);
|
||||
this.errorMessage = null; // Clear any previous error
|
||||
this.errorMessage = null;
|
||||
} else {
|
||||
console.warn('Could not find machine with ID:', machineId);
|
||||
this.errorMessage = 'Machine not found. Please select a machine from the list.';
|
||||
this.loadFallbackMachine();
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
error => {
|
||||
error: (error) => {
|
||||
console.error('Error fetching machine:', error);
|
||||
this.errorMessage = 'Error loading machine details. Please try again.';
|
||||
this.isLoading = false;
|
||||
this.loadFallbackMachine();
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// If no ID in route, try to load from selected machine
|
||||
this.loadFallbackMachine();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to selected machine from service
|
||||
*/
|
||||
private loadFallbackMachine(): void {
|
||||
const sub = this.machineService.selectedMachine$.subscribe(machine => {
|
||||
if (machine?.machine_id) {
|
||||
this.machine = machine;
|
||||
this.loadVendingState(machine.machine_id);
|
||||
// Update the URL with the machine ID to handle refresh
|
||||
this.router.navigate(['/machines', machine.id], {
|
||||
replaceUrl: true,
|
||||
skipLocationChange: false
|
||||
});
|
||||
this.errorMessage = null; // Clear any previous error
|
||||
this.errorMessage = null;
|
||||
} else {
|
||||
console.warn('No machine selected and no ID in route');
|
||||
this.errorMessage = 'No machine selected. Please select a machine from the list.';
|
||||
// Instead of navigating away, show an error message on the UI
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(sub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load vending state for machine
|
||||
*/
|
||||
loadVendingState(machineId: string): void {
|
||||
this.machineService.getMachineVendingState(machineId).subscribe(
|
||||
response => {
|
||||
this.machineService.getMachineVendingState(machineId).subscribe({
|
||||
next: (response) => {
|
||||
if (response?.vendingRows) {
|
||||
this.updateSlotsFromMachine(response.vendingRows);
|
||||
}
|
||||
},
|
||||
error => console.error('Error loading vending state:', error)
|
||||
);
|
||||
error: (error) => console.error('Error loading vending state:', error)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local slots with data from backend
|
||||
*/
|
||||
updateSlotsFromMachine(machineSlots: any[]): void {
|
||||
if (!machineSlots?.length) return;
|
||||
|
||||
@ -133,7 +165,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
const rowIndex = this.vendingRows.findIndex(r => r.id === rowData.rowId);
|
||||
if (rowIndex !== -1 && rowData.slots) {
|
||||
rowData.slots.forEach((slotData: any) => {
|
||||
const slotIndex = this.vendingRows[rowIndex].slots.findIndex(s => s.name === slotData.name);
|
||||
const slotIndex = this.vendingRows[rowIndex].slots.findIndex(
|
||||
s => s.name === slotData.name
|
||||
);
|
||||
if (slotIndex !== -1) {
|
||||
const slot = this.vendingRows[rowIndex].slots[slotIndex];
|
||||
slot.enabled = slotData.enabled;
|
||||
@ -151,24 +185,71 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all products
|
||||
*/
|
||||
loadProducts(): void {
|
||||
this.productService.getProducts().subscribe(
|
||||
data => {
|
||||
this.productService.getProducts().subscribe({
|
||||
next: (data) => {
|
||||
this.products = data;
|
||||
if (this.machine?.machine_id) {
|
||||
this.loadVendingState(this.machine.machine_id);
|
||||
}
|
||||
},
|
||||
error => console.error('Error loading products:', error)
|
||||
);
|
||||
error: (error) => console.error('Error loading products:', error)
|
||||
});
|
||||
}
|
||||
|
||||
toggleSlotStatus(row: any, slot: any, event: MouseEvent): void {
|
||||
/**
|
||||
* Get full image URL for a product
|
||||
*/
|
||||
getImageUrl(imagePath: string | null | undefined): string {
|
||||
if (!imagePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove leading slash if present
|
||||
const cleanPath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
|
||||
|
||||
return `${environment.apiUrl}/${cleanPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image has a valid URL
|
||||
*/
|
||||
hasValidImage(imagePath: string | null | undefined): boolean {
|
||||
if (!imagePath) return false;
|
||||
|
||||
const imageUrl = this.getImageUrl(imagePath);
|
||||
return imageUrl !== '' && !this.imageLoadErrors.has(imageUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle image loading errors
|
||||
*/
|
||||
onImageError(event: any, imagePath: string | null | undefined): void {
|
||||
if (imagePath) {
|
||||
const imageUrl = this.getImageUrl(imagePath);
|
||||
console.error('Image failed to load:', imageUrl);
|
||||
this.imageLoadErrors.add(imageUrl);
|
||||
|
||||
// Hide the image by setting display to none
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle slot enabled/disabled status
|
||||
*/
|
||||
toggleSlotStatus(row: VendingRow, slot: Slot, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
slot.enabled = !slot.enabled;
|
||||
}
|
||||
|
||||
openProductSelector(row: any, slot: any, event: Event): void {
|
||||
/**
|
||||
* Open product selector modal
|
||||
*/
|
||||
openProductSelector(row: VendingRow, slot: Slot, event: Event): void {
|
||||
if (!slot.enabled) return;
|
||||
|
||||
event.stopPropagation();
|
||||
@ -177,10 +258,14 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
this.showProductSelector = true;
|
||||
this.filteredProducts = [...this.products];
|
||||
|
||||
// Reset selection states
|
||||
this.filteredProducts.forEach(p => p.selected = false);
|
||||
|
||||
// Pre-select product if slot has one
|
||||
if (slot.product) {
|
||||
const existingProduct = this.filteredProducts.find(p => p.product_id === slot.product.product_id);
|
||||
const existingProduct = this.filteredProducts.find(
|
||||
p => p.product_id === slot.product?.product_id
|
||||
);
|
||||
if (existingProduct) {
|
||||
existingProduct.selected = true;
|
||||
this.selectedProduct = existingProduct;
|
||||
@ -195,6 +280,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
this.filterProducts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close product selector modal
|
||||
*/
|
||||
closeProductSelector(): void {
|
||||
this.showProductSelector = false;
|
||||
this.selectedRow = null;
|
||||
@ -203,11 +291,16 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
this.productQuantity = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter products based on search term
|
||||
*/
|
||||
filterProducts(): void {
|
||||
if (!this.searchTerm.trim()) {
|
||||
this.filteredProducts = [...this.products];
|
||||
if (this.selectedProduct) {
|
||||
const selectedProduct = this.filteredProducts.find(p => p.product_id === this.selectedProduct.product_id);
|
||||
const selectedProduct = this.filteredProducts.find(
|
||||
p => p.product_id === this.selectedProduct?.product_id
|
||||
);
|
||||
if (selectedProduct) selectedProduct.selected = true;
|
||||
}
|
||||
return;
|
||||
@ -216,21 +309,27 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
const term = this.searchTerm.toLowerCase().trim();
|
||||
this.filteredProducts = this.products.filter(product =>
|
||||
product.product_name.toLowerCase().includes(term) ||
|
||||
product.product_id.toString().includes(term) ||
|
||||
product.product_id?.toString().includes(term) ||
|
||||
(product.category && product.category.toLowerCase().includes(term))
|
||||
);
|
||||
|
||||
if (this.selectedProduct) {
|
||||
const selectedProduct = this.filteredProducts.find(p => p.product_id === this.selectedProduct.product_id);
|
||||
const selectedProduct = this.filteredProducts.find(
|
||||
p => p.product_id === this.selectedProduct?.product_id
|
||||
);
|
||||
if (selectedProduct) selectedProduct.selected = true;
|
||||
}
|
||||
}
|
||||
|
||||
onProductSelectionChange(product: any): void {
|
||||
/**
|
||||
* Handle product selection change
|
||||
*/
|
||||
onProductSelectionChange(product: Product): void {
|
||||
this.filteredProducts.forEach(p => p.selected = false);
|
||||
product.selected = true;
|
||||
this.selectedProduct = product;
|
||||
|
||||
// Pre-fill quantity if same product is already in slot
|
||||
if (this.selectedSlot?.product?.product_id === product.product_id) {
|
||||
this.productQuantity = this.selectedSlot.units || 1;
|
||||
} else {
|
||||
@ -238,19 +337,26 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase product quantity
|
||||
*/
|
||||
increaseQuantity(): void {
|
||||
this.productQuantity++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrease product quantity
|
||||
*/
|
||||
decreaseQuantity(): void {
|
||||
// ✅ Allow decreasing quantity (minimum 1)
|
||||
if (this.productQuantity > 1) {
|
||||
this.productQuantity--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set quantity from input field
|
||||
*/
|
||||
setQuantity(event: any): void {
|
||||
// ✅ Allow setting quantity always
|
||||
const value = parseInt(event.target.value);
|
||||
this.productQuantity = (!isNaN(value) && value > 0) ? value : 1;
|
||||
if (this.productQuantity < 1) {
|
||||
@ -259,6 +365,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm product selection and assign to slot
|
||||
*/
|
||||
confirmProductSelection(): void {
|
||||
if (!this.selectedProduct || !this.selectedSlot) return;
|
||||
|
||||
@ -269,13 +378,19 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
this.closeProductSelector();
|
||||
}
|
||||
|
||||
removeProductFromSlot(slot: any, event: Event): void {
|
||||
/**
|
||||
* Remove product from slot
|
||||
*/
|
||||
removeProductFromSlot(slot: Slot, event: Event): void {
|
||||
event.stopPropagation();
|
||||
slot.product = null;
|
||||
slot.units = 0;
|
||||
slot.price = 'N/A';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all changes to backend
|
||||
*/
|
||||
saveChanges(): void {
|
||||
if (!this.machine.machine_id) {
|
||||
console.error('Cannot save: No machine ID available');
|
||||
@ -294,21 +409,27 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
||||
}))
|
||||
}));
|
||||
|
||||
this.isLoading = true;
|
||||
this.machineService.updateMachineVendingState(this.machine.machine_id, vendingState)
|
||||
.subscribe(
|
||||
response => {
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
console.log('Vending state updated:', response);
|
||||
alert('Machine configuration saved successfully!');
|
||||
this.errorMessage = null;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error => {
|
||||
error: (error) => {
|
||||
console.error('Error updating vending state:', error);
|
||||
this.errorMessage = 'Failed to save machine configuration. Please try again.';
|
||||
this.isLoading = false;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
goBack() {
|
||||
/**
|
||||
* Navigate back to machines list
|
||||
*/
|
||||
goBack(): void {
|
||||
this.router.navigate(['/machines/machines-list']);
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,33 @@ export interface Machine {
|
||||
last_maintenance_date?: string;
|
||||
next_maintenance_date?: string;
|
||||
firmware_version?: string;
|
||||
|
||||
// Created by fields (automatic from backend)
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
|
||||
// ⭐ Option 3: Assigned Refillers
|
||||
assigned_refillers?: AssignedRefiller[];
|
||||
|
||||
// UI helper for password toggle
|
||||
showPassword?: boolean;
|
||||
}
|
||||
|
||||
export interface AssignedRefiller {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
assigned_at: string;
|
||||
}
|
||||
|
||||
export interface Refiller {
|
||||
id: number;
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
roles: string;
|
||||
assigned_to?: number;
|
||||
assigned_to_username?: string;
|
||||
}
|
||||
|
||||
export interface VendingStateResponse {
|
||||
@ -34,6 +61,7 @@ export interface VendingStateResponse {
|
||||
export class MachineService {
|
||||
private apiUrl = `${environment.apiUrl}/machines`;
|
||||
private usersUrl = `${environment.apiUrl}/users`;
|
||||
private backendBaseUrl = environment.apiUrl;
|
||||
|
||||
// 🔹 Loading state
|
||||
private loadingSource = new BehaviorSubject<boolean>(false);
|
||||
@ -45,9 +73,10 @@ export class MachineService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// =====================
|
||||
// 🔹 Shared state methods
|
||||
// =====================
|
||||
// =============================================
|
||||
// SHARED STATE METHODS
|
||||
// =============================================
|
||||
|
||||
setSelectedMachine(machine: Machine): void {
|
||||
this.selectedMachineSource.next(machine);
|
||||
}
|
||||
@ -56,9 +85,10 @@ export class MachineService {
|
||||
this.selectedMachineSource.next(null);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 🔹 CRUD methods
|
||||
// =====================
|
||||
// =============================================
|
||||
// BASIC MACHINE CRUD OPERATIONS
|
||||
// =============================================
|
||||
|
||||
getMachines(): Observable<Machine[]> {
|
||||
this.loadingSource.next(true);
|
||||
return this.http.get<Machine[]>(this.apiUrl).pipe(
|
||||
@ -68,16 +98,25 @@ export class MachineService {
|
||||
);
|
||||
}
|
||||
|
||||
getUsers(): Observable<any[]> {
|
||||
return this.http.get<any[]>(this.usersUrl).pipe(
|
||||
tap((users) => console.log('Fetched users:', users)),
|
||||
catchError(this.handleError<any[]>('getUsers', []))
|
||||
getMachineById(id: number): Observable<Machine> {
|
||||
return this.http.get<Machine>(`${this.apiUrl}/${id}`).pipe(
|
||||
tap((m) => console.log('Fetched machine by id:', m)),
|
||||
catchError(this.handleError<Machine>('getMachineById', {} as Machine))
|
||||
);
|
||||
}
|
||||
|
||||
addMachine(machine: Machine): Observable<Machine> {
|
||||
this.loadingSource.next(true);
|
||||
return this.http.post<Machine>(this.apiUrl, machine).pipe(
|
||||
|
||||
// Don't send created_by in the request body
|
||||
// Backend automatically extracts it from JWT token
|
||||
const payload = { ...machine };
|
||||
delete payload.created_by;
|
||||
delete payload.created_by_username;
|
||||
delete payload.assigned_refillers;
|
||||
delete payload.showPassword;
|
||||
|
||||
return this.http.post<Machine>(this.apiUrl, payload).pipe(
|
||||
tap((newMachine) => console.log('Added new machine:', newMachine)),
|
||||
catchError((error) => {
|
||||
console.error('Error adding machine:', error);
|
||||
@ -89,34 +128,88 @@ export class MachineService {
|
||||
}
|
||||
|
||||
updateMachine(id: number, machine: Machine): Observable<Machine> {
|
||||
this.loadingSource.next(true);
|
||||
return this.http.put<Machine>(`${this.apiUrl}/${id}`, machine).pipe(
|
||||
tap((updated) => console.log('Updated machine:', updated)),
|
||||
catchError(this.handleError<Machine>('updateMachine', {} as Machine)),
|
||||
tap(() => this.loadingSource.next(false))
|
||||
);
|
||||
}
|
||||
|
||||
deleteMachine(id: number): Observable<any> {
|
||||
this.loadingSource.next(true);
|
||||
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
|
||||
tap(() => console.log('Deleted machine with ID:', id)),
|
||||
catchError(this.handleError<any>('deleteMachine')),
|
||||
tap(() => this.loadingSource.next(false))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// =====================
|
||||
// 🔹 Advanced methods
|
||||
// =====================
|
||||
getMachineById(id: number): Observable<Machine> {
|
||||
return this.http.get<Machine>(`${this.apiUrl}/${id}`).pipe(
|
||||
tap((m) => console.log('Fetched machine by id:', m)),
|
||||
catchError(this.handleError<Machine>('getMachineById', {} as Machine))
|
||||
this.loadingSource.next(true);
|
||||
|
||||
// Don't send created_by or assigned_refillers in updates
|
||||
const payload = { ...machine };
|
||||
delete payload.created_by;
|
||||
delete payload.created_by_username;
|
||||
delete payload.assigned_refillers;
|
||||
delete payload.showPassword;
|
||||
|
||||
return this.http.put<Machine>(`${this.apiUrl}/${id}`, payload).pipe(
|
||||
tap((updated) => console.log('Updated machine:', updated)),
|
||||
catchError(this.handleError<Machine>('updateMachine', {} as Machine)),
|
||||
tap(() => this.loadingSource.next(false))
|
||||
);
|
||||
}
|
||||
|
||||
deleteMachine(id: number): Observable<any> {
|
||||
this.loadingSource.next(true);
|
||||
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
|
||||
tap(() => console.log('Deleted machine with ID:', id)),
|
||||
catchError(this.handleError<any>('deleteMachine')),
|
||||
tap(() => this.loadingSource.next(false))
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// USER RELATED (for dropdowns)
|
||||
// =============================================
|
||||
|
||||
getUsers(): Observable<any[]> {
|
||||
return this.http.get<any[]>(this.usersUrl).pipe(
|
||||
tap((users) => console.log('Fetched users:', users)),
|
||||
catchError(this.handleError<any[]>('getUsers', []))
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// OPTION 3: MACHINE REFILLER ASSIGNMENTS
|
||||
// =============================================
|
||||
|
||||
/**
|
||||
* Get all Refillers assigned to a Machine
|
||||
* @param machineId - The machine_id (e.g., "M001")
|
||||
*/
|
||||
getMachineRefillers(machineId: string): Observable<Refiller[]> {
|
||||
return this.http.get<Refiller[]>(`${this.apiUrl}/${machineId}/refillers`).pipe(
|
||||
tap((refillers) => console.log(`Fetched refillers for machine ${machineId}:`, refillers)),
|
||||
catchError(this.handleError<Refiller[]>('getMachineRefillers', []))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign Refillers to a Machine
|
||||
* @param machineId - The machine_id (e.g., "M001")
|
||||
* @param refillerIds - Array of Refiller user IDs
|
||||
*/
|
||||
assignRefillersToMachine(machineId: string, refillerIds: number[]): Observable<any> {
|
||||
return this.http.post(`${this.apiUrl}/${machineId}/refillers`, { refiller_ids: refillerIds }).pipe(
|
||||
tap((response) => console.log(`Assigned refillers to machine ${machineId}:`, response)),
|
||||
catchError(this.handleError<any>('assignRefillersToMachine'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available Refillers that can be assigned to a Machine
|
||||
* Filters by Refillers assigned to the Machine's Client
|
||||
*/
|
||||
getAvailableRefillers(clientId?: number): Observable<Refiller[]> {
|
||||
const url = clientId
|
||||
? `${this.usersUrl}?roles=Refiller&assigned_to=${clientId}`
|
||||
: `${this.usersUrl}?roles=Refiller`;
|
||||
|
||||
return this.http.get<Refiller[]>(url).pipe(
|
||||
tap((refillers) => console.log('Fetched available refillers:', refillers)),
|
||||
catchError(this.handleError<Refiller[]>('getAvailableRefillers', []))
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// MACHINE STATUS OPERATIONS
|
||||
// =============================================
|
||||
|
||||
updateMachineStatus(
|
||||
id: number,
|
||||
status: { operation_status?: string; connection_status?: string }
|
||||
@ -127,9 +220,13 @@ deleteMachine(id: number): Observable<any> {
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// VENDING STATE OPERATIONS
|
||||
// =============================================
|
||||
|
||||
getMachineVendingState(machineId: string): Observable<VendingStateResponse> {
|
||||
return this.http
|
||||
.get<VendingStateResponse>(`${this.apiUrl}/${machineId}`)
|
||||
.get<VendingStateResponse>(`${this.apiUrl}/${machineId}/vending-state`)
|
||||
.pipe(
|
||||
tap((state) => console.log('Fetched vending state:', state)),
|
||||
catchError(
|
||||
@ -149,6 +246,10 @@ deleteMachine(id: number): Observable<any> {
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// MAINTENANCE OPERATIONS
|
||||
// =============================================
|
||||
|
||||
getMachineMaintenanceHistory(machineId: string): Observable<any[]> {
|
||||
return this.http
|
||||
.get<any[]>(`${this.apiUrl}/${machineId}/maintenance-history`)
|
||||
@ -165,13 +266,19 @@ deleteMachine(id: number): Observable<any> {
|
||||
);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 🔹 Error handler
|
||||
// =====================
|
||||
// =============================================
|
||||
// ERROR HANDLER
|
||||
// =============================================
|
||||
|
||||
private handleError<T>(operation = 'operation', result?: T) {
|
||||
return (error: HttpErrorResponse): Observable<T> => {
|
||||
console.error(`${operation} failed:`, error.message);
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
console.error('Client-side error:', error.error.message);
|
||||
} else {
|
||||
console.error(`Backend returned code ${error.status}, body:`, error.error);
|
||||
}
|
||||
return of(result as T);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -47,6 +47,7 @@
|
||||
<div>Operation</div>
|
||||
<div>Connection</div>
|
||||
<div>Password</div>
|
||||
<div>Created By</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
@ -72,6 +73,12 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Created By -->
|
||||
<div class="flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4 text-gray-500" svgIcon="heroicons_outline:user"></mat-icon>
|
||||
<span>{{ machine.created_by_username || 'N/A' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Edit button - ONLY for admins -->
|
||||
@ -145,6 +152,7 @@
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Branch -->
|
||||
<mat-form-field class="w-full sm:w-1/2 pl-2">
|
||||
<mat-label>Branch Name</mat-label>
|
||||
@ -176,6 +184,13 @@
|
||||
<input matInput formControlName="password" type="password" />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Created By - Display only (read-only) -->
|
||||
<mat-form-field class="w-full sm:w-1/2 pl-2" *ngIf="selectedMachine?.id !== null && selectedMachine?.created_by_username">
|
||||
<mat-label>Created By</mat-label>
|
||||
<input matInput [value]="selectedMachine.created_by_username" readonly />
|
||||
<mat-icon matPrefix class="icon-size-5" svgIcon="heroicons_outline:user"></mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
/* ============================================
|
||||
CRITICAL RESPONSIVE FIXES FOR MACHINES LIST
|
||||
MACHINES LIST - COMPLETE STYLES WITH DROPDOWN FIXES
|
||||
============================================ */
|
||||
|
||||
/* ============================================
|
||||
CRITICAL RESPONSIVE FIXES
|
||||
============================================ */
|
||||
|
||||
/* Remove the min-width constraint that causes overflow */
|
||||
.grid.min-w-max {
|
||||
min-width: 0 !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure parent containers allow proper responsive behavior */
|
||||
.flex.flex-auto.overflow-auto {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@ -18,6 +20,177 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DROPDOWN (MAT-SELECT) FIXES - CRITICAL
|
||||
============================================ */
|
||||
|
||||
/* Base mat-select styling */
|
||||
.mat-mdc-form-field mat-select {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dropdown trigger */
|
||||
::ng-deep .mat-mdc-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Selected value display */
|
||||
::ng-deep .mat-mdc-select-value {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-select-value-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dropdown arrow */
|
||||
::ng-deep .mat-mdc-select-arrow-wrapper {
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-select-arrow {
|
||||
transition: transform 0.2s ease;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.dark ::ng-deep .mat-mdc-select-arrow {
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-select.mat-mdc-select-panel-open .mat-mdc-select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Dropdown panel */
|
||||
::ng-deep .mat-mdc-select-panel {
|
||||
min-width: 100% !important;
|
||||
max-width: 400px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dark ::ng-deep .mat-mdc-select-panel {
|
||||
background-color: #2d3748;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Dropdown options */
|
||||
::ng-deep .mat-mdc-option {
|
||||
padding: 12px 16px;
|
||||
min-height: 48px;
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-option:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dark ::ng-deep .mat-mdc-option:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-option.mat-mdc-option-active,
|
||||
::ng-deep .mat-mdc-option.mdc-list-item--selected {
|
||||
background-color: rgba(25, 118, 210, 0.12);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark ::ng-deep .mat-mdc-option.mat-mdc-option-active,
|
||||
.dark ::ng-deep .mat-mdc-option.mdc-list-item--selected {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
/* Placeholder styling */
|
||||
::ng-deep .mat-mdc-select-placeholder {
|
||||
color: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.dark ::ng-deep .mat-mdc-select-placeholder {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FORM FIELD LAYOUT
|
||||
============================================ */
|
||||
|
||||
/* Form container */
|
||||
.flex.flex-col.p-8.sm\:flex-row {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.flex.flex-auto.flex-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Form field base styling */
|
||||
.mat-mdc-form-field {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Two-column layout on desktop */
|
||||
.mat-mdc-form-field.w-full.sm\:w-1\/2 {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.mat-mdc-form-field.w-full.sm\:w-1\/2 {
|
||||
width: calc(50% - 8px);
|
||||
}
|
||||
|
||||
.mat-mdc-form-field.w-full.sm\:w-1\/2.pr-2 {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field.w-full.sm\:w-1\/2.pl-2 {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form field internals */
|
||||
::ng-deep .mat-mdc-form-field-infix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-text-field-wrapper {
|
||||
padding: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-flex {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-appearance-outline .mat-mdc-form-field-outline {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
INVENTORY GRID - BASE LAYOUT
|
||||
============================================ */
|
||||
@ -26,146 +199,115 @@
|
||||
display: grid;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Desktop - All 10 columns visible */
|
||||
.inventory-grid > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Desktop - All 11 columns */
|
||||
@media (min-width: 1400px) {
|
||||
.inventory-grid {
|
||||
grid-template-columns:
|
||||
60px /* 1. ID */
|
||||
140px /* 2. Machine ID */
|
||||
180px /* 3. Model */
|
||||
140px /* 4. Type */
|
||||
180px /* 5. Client */
|
||||
180px /* 6. Branch */
|
||||
150px /* 7. Operation */
|
||||
150px /* 8. Connection */
|
||||
150px /* 9. Password */
|
||||
200px; /* 10. Actions */
|
||||
60px 140px 180px 140px 180px 180px 150px 150px 150px 150px 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large Laptop (1024px - 1399px) */
|
||||
/* Large Laptop */
|
||||
@media (min-width: 1024px) and (max-width: 1399px) {
|
||||
.inventory-grid {
|
||||
grid-template-columns:
|
||||
50px /* 1. ID */
|
||||
120px /* 2. Machine ID */
|
||||
150px /* 3. Model */
|
||||
110px /* 4. Type */
|
||||
140px /* 5. Client */
|
||||
140px /* 6. Branch */
|
||||
130px /* 7. Operation */
|
||||
130px /* 8. Connection */
|
||||
120px /* 9. Password */
|
||||
170px; /* 10. Actions */
|
||||
50px 120px 150px 110px 140px 140px 130px 130px 120px 130px 170px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet Landscape (768px - 1023px) - Hide Type, Connection, Password */
|
||||
/* Tablet Landscape */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.inventory-grid {
|
||||
grid-template-columns:
|
||||
50px /* 1. ID */
|
||||
120px /* 2. Machine ID */
|
||||
150px /* 3. Model */
|
||||
minmax(120px, 1fr) /* 5. Client */
|
||||
130px /* 6. Branch */
|
||||
120px /* 7. Operation */
|
||||
160px; /* 10. Actions */
|
||||
50px 120px 150px minmax(120px, 1fr) 130px 120px 160px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Hide Type (column 4) */
|
||||
.inventory-grid > div:nth-child(4),
|
||||
.inventory-grid > div:nth-child(8),
|
||||
.inventory-grid > div:nth-child(9) {
|
||||
.inventory-grid > div:nth-child(9),
|
||||
.inventory-grid > div:nth-child(10) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet Portrait (576px - 767px) - Show ID, Machine ID, Model, Actions */
|
||||
/* Tablet Portrait */
|
||||
@media (min-width: 576px) and (max-width: 767px) {
|
||||
.inventory-grid {
|
||||
grid-template-columns:
|
||||
45px /* 1. ID */
|
||||
minmax(100px, 1fr) /* 2. Machine ID */
|
||||
140px /* 3. Model */
|
||||
180px; /* 10. Actions */
|
||||
padding: 10px 12px;
|
||||
grid-template-columns: 45px minmax(100px, 1fr) 140px 180px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Hide columns 4-9 */
|
||||
.inventory-grid > div:nth-child(4),
|
||||
.inventory-grid > div:nth-child(5),
|
||||
.inventory-grid > div:nth-child(6),
|
||||
.inventory-grid > div:nth-child(7),
|
||||
.inventory-grid > div:nth-child(8),
|
||||
.inventory-grid > div:nth-child(9) {
|
||||
.inventory-grid > div:nth-child(9),
|
||||
.inventory-grid > div:nth-child(10) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Adjust action buttons */
|
||||
.inventory-grid .flex.gap-2 {
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory-grid button {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
min-height: 32px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile (< 576px) - Show only ID, Machine ID, Actions */
|
||||
/* Mobile */
|
||||
@media (max-width: 575px) {
|
||||
.inventory-grid {
|
||||
grid-template-columns:
|
||||
40px /* 1. ID */
|
||||
minmax(80px, 1fr) /* 2. Machine ID */
|
||||
130px; /* 10. Actions */
|
||||
padding: 8px 8px;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
grid-template-columns: 40px minmax(80px, 1fr) 130px;
|
||||
padding: 12px 12px;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Hide columns 3-9 */
|
||||
.inventory-grid > div:nth-child(3),
|
||||
.inventory-grid > div:nth-child(4),
|
||||
.inventory-grid > div:nth-child(5),
|
||||
.inventory-grid > div:nth-child(6),
|
||||
.inventory-grid > div:nth-child(7),
|
||||
.inventory-grid > div:nth-child(8),
|
||||
.inventory-grid > div:nth-child(9) {
|
||||
.inventory-grid > div:nth-child(9),
|
||||
.inventory-grid > div:nth-child(10) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Stack action buttons vertically */
|
||||
.inventory-grid .flex.gap-2 {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inventory-grid button {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
padding: 6px 4px;
|
||||
min-height: 36px;
|
||||
font-size: 12px;
|
||||
padding: 8px 6px;
|
||||
min-height: 40px;
|
||||
min-width: auto !important;
|
||||
}
|
||||
|
||||
.inventory-grid button mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER ROW SPECIFIC STYLING
|
||||
HEADER ROW STYLING
|
||||
============================================ */
|
||||
|
||||
.inventory-grid.text-secondary {
|
||||
@ -173,6 +315,7 @@
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@ -184,13 +327,6 @@
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.inventory-grid.text-secondary {
|
||||
font-size: 10px;
|
||||
padding: 8px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DATA ROW STYLING
|
||||
============================================ */
|
||||
@ -201,36 +337,62 @@
|
||||
}
|
||||
|
||||
.inventory-grid:not(.text-secondary):hover {
|
||||
background-color: #f3f4f6;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.dark .inventory-grid:not(.text-secondary) {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dark .inventory-grid:not(.text-secondary):hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Text overflow handling */
|
||||
.inventory-grid > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Actions column should wrap on mobile */
|
||||
.inventory-grid > div:last-child {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER SECTION (Top bar with search & add button)
|
||||
CREATED BY COLUMN
|
||||
============================================ */
|
||||
|
||||
.inventory-grid > div:nth-child(10) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory-grid > div:nth-child(10) mat-icon {
|
||||
color: #6b7280;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.inventory-grid > div:nth-child(10) span {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .inventory-grid > div:nth-child(10) mat-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .inventory-grid > div:nth-child(10) span {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER SECTION
|
||||
============================================ */
|
||||
|
||||
.relative.flex.flex-0.flex-col.border-b {
|
||||
padding: 16px;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.relative.flex.flex-0.flex-col.border-b {
|
||||
padding: 16px 12px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.text-4xl.font-extrabold.tracking-tight {
|
||||
@ -238,7 +400,6 @@
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
/* Search and button container */
|
||||
.mt-6.flex.shrink-0.items-center {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
@ -247,13 +408,11 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Search field takes full width */
|
||||
mat-form-field.fuse-mat-dense.fuse-mat-rounded.min-w-64 {
|
||||
min-width: 0 !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Add button takes full width */
|
||||
.mt-6.flex.shrink-0.items-center > button.ml-4 {
|
||||
margin-left: 0 !important;
|
||||
width: 100%;
|
||||
@ -261,106 +420,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.relative.flex.flex-0.flex-col.border-b {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.text-4xl.font-extrabold.tracking-tight {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPANDED FORM (Create/Edit Form)
|
||||
============================================ */
|
||||
|
||||
/* Form container */
|
||||
.overflow-hidden.shadow-lg {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Form fields container */
|
||||
.flex.flex-col.p-8.sm\:flex-row {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.flex.flex-col.p-8.sm\:flex-row {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Make all form fields full width */
|
||||
.mat-form-field.w-full.sm\:w-1\/2 {
|
||||
width: 100% !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mat-form-field.w-full.sm\:w-1\/2.pr-2,
|
||||
.mat-form-field.w-full.sm\:w-1\/2.pl-2 {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FORM FOOTER BUTTONS (Delete/Create/Update)
|
||||
FORM FOOTER BUTTONS
|
||||
============================================ */
|
||||
|
||||
.flex.w-full.items-center.justify-between.border-t {
|
||||
padding: 16px 24px;
|
||||
padding: 20px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Delete button */
|
||||
.flex.w-full.items-center.justify-between.border-t > button.-ml-4 {
|
||||
margin-left: 0 !important;
|
||||
min-height: 40px;
|
||||
min-height: 44px;
|
||||
padding: 0 24px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.flex.w-full.items-center.justify-between.border-t > button.-ml-4:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
/* Right side container (flash message + action button) */
|
||||
.flex.w-full.items-center.justify-between.border-t > .flex.items-center {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Create/Update button */
|
||||
.flex.w-full.items-center.justify-between.border-t button[color="primary"] {
|
||||
min-height: 40px;
|
||||
padding: 0 24px;
|
||||
min-height: 44px;
|
||||
padding: 0 32px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.flex.w-full.items-center.justify-between.border-t button[color="primary"]:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.flex.w-full.items-center.justify-between.border-t {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Delete button full width */
|
||||
.flex.w-full.items-center.justify-between.border-t > button.-ml-4 {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* Empty div placeholder - hide on mobile */
|
||||
.flex.w-full.items-center.justify-between.border-t > div:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Right side container full width */
|
||||
.flex.w-full.items-center.justify-between.border-t > .flex.items-center {
|
||||
width: 100%;
|
||||
order: 1;
|
||||
@ -368,29 +489,32 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Flash message centered */
|
||||
.flex.w-full.items-center.justify-between.border-t .mr-4.flex.items-center {
|
||||
margin-right: 0 !important;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Create/Update button full width */
|
||||
.flex.w-full.items-center.justify-between.border-t button[color="primary"] {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.flex.w-full.items-center.justify-between.border-t {
|
||||
padding: 12px 8px;
|
||||
|
||||
.mat-mdc-form-field.w-full.sm\:w-1\/2 {
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 0 16px 0 !important;
|
||||
}
|
||||
|
||||
.flex.w-full.items-center.justify-between.border-t button {
|
||||
min-height: 52px;
|
||||
font-size: 14px;
|
||||
.mat-mdc-form-field.w-full.sm\:w-1\/2.pr-2,
|
||||
.mat-mdc-form-field.w-full.sm\:w-1\/2.pl-2 {
|
||||
padding: 0 !important;
|
||||
margin: 0 0 16px 0 !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-select-panel {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -400,16 +524,83 @@
|
||||
|
||||
.mr-4.flex.items-center {
|
||||
transition: opacity 0.3s ease;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mr-4.flex.items-center mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.mr-4.flex.items-center {
|
||||
font-size: 13px;
|
||||
}
|
||||
.mr-4.flex.items-center:has(mat-icon.text-green-500) {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.mr-4.flex.items-center:has(mat-icon.text-red-500) {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.dark .mr-4.flex.items-center:has(mat-icon.text-green-500) {
|
||||
background-color: rgba(72, 187, 120, 0.2);
|
||||
color: #68d391;
|
||||
border-color: rgba(72, 187, 120, 0.3);
|
||||
}
|
||||
|
||||
.dark .mr-4.flex.items-center:has(mat-icon.text-red-500) {
|
||||
background-color: rgba(245, 101, 101, 0.2);
|
||||
color: #fc8181;
|
||||
border-color: rgba(245, 101, 101, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PASSWORD FIELD
|
||||
============================================ */
|
||||
|
||||
.flex.items-center.gap-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.flex.items-center.gap-1 span {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flex.items-center.gap-1 button {
|
||||
padding: 6px;
|
||||
min-width: auto;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.flex.items-center.gap-1 button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dark .flex.items-center.gap-1 button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ACTION BUTTONS
|
||||
============================================ */
|
||||
|
||||
.inventory-grid .flex.gap-2 button {
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.inventory-grid .flex.gap-2 button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@ -418,174 +609,102 @@
|
||||
|
||||
mat-paginator {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
mat-paginator {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mat-mdc-paginator-container {
|
||||
padding: 8px !important;
|
||||
padding: 12px !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPANDED FORM
|
||||
============================================ */
|
||||
|
||||
.overflow-hidden.shadow-lg {
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.flex.flex-col.p-8.sm\:flex-row {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mat-mdc-paginator-page-size {
|
||||
margin: 0 !important;
|
||||
::ng-deep .mat-mdc-form-field-infix {
|
||||
min-height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PASSWORD FIELD WITH TOGGLE
|
||||
============================================ */
|
||||
|
||||
.flex.items-center.gap-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.flex.items-center.gap-1 button {
|
||||
padding: 4px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.flex.items-center.gap-1 button mat-icon {
|
||||
font-size: 18px;
|
||||
|
||||
::ng-deep .mat-mdc-option {
|
||||
padding: 10px 12px;
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATUS BADGES & INDICATORS
|
||||
============================================ */
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-badge.maintenance {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MATERIAL FORM FIELD OVERRIDES
|
||||
============================================ */
|
||||
|
||||
.mat-mdc-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-appearance-outline .mat-mdc-form-field-outline {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
|
||||
.border-t.p-8.text-center {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.border-t.p-8.text-center {
|
||||
padding: 32px 16px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
padding: 60px 32px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DARK MODE SUPPORT
|
||||
DARK MODE
|
||||
============================================ */
|
||||
|
||||
.dark .flex.border-b {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dark .border-b {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dark .flex.border-b,
|
||||
.dark .border-b,
|
||||
.dark .border-t {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dark .overflow-hidden.shadow-lg {
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
FOCUS STATES
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sm\:absolute {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.sm\:inset-0 {
|
||||
inset: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Focus states for accessibility */
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-select:focus {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PRINT STYLES
|
||||
CLIENT VIEW NOTICE
|
||||
============================================ */
|
||||
|
||||
@media print {
|
||||
.inventory-grid {
|
||||
grid-template-columns: auto auto auto auto auto auto auto auto auto auto;
|
||||
font-size: 10px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.inventory-grid button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
display: none;
|
||||
}
|
||||
p.mt-2.text-sm.text-gray-600 {
|
||||
padding: 8px 16px;
|
||||
background-color: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
border-radius: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.dark p.mt-2.text-sm.text-gray-600 {
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
border-left-color: #fbbf24;
|
||||
color: #fcd34d;
|
||||
}
|
||||
@ -1 +1,335 @@
|
||||
<p>brand works!</p>
|
||||
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
|
||||
<!-- Loader -->
|
||||
@if (isLoading) {
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
|
||||
</div>
|
||||
}
|
||||
<!-- Title -->
|
||||
<div class="text-4xl font-extrabold tracking-tight">Brand Management</div>
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
|
||||
<!-- Search -->
|
||||
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
|
||||
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
|
||||
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
|
||||
[placeholder]="'Search brands'" />
|
||||
</mat-form-field>
|
||||
<!-- Add brand button -->
|
||||
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="createBrand()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||
<span class="ml-2 mr-1">Add Brand</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex flex-auto overflow-hidden">
|
||||
<!-- Brands list -->
|
||||
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
|
||||
@if (brands$ | async; as brands) {
|
||||
@if (brands.length > 0 || selectedBrand) {
|
||||
<div class="grid">
|
||||
<!-- Header -->
|
||||
<div class="brand-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-sm font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
|
||||
matSort matSortDisableClear>
|
||||
<div [mat-sort-header]="'brand_id'">Brand ID</div>
|
||||
<div [mat-sort-header]="'name'">Name</div>
|
||||
<div class="hidden md:block" [mat-sort-header]="'branch_id'">Branch ID</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'branch_name'">Branch Name</div>
|
||||
<div class="hidden sm:block">Image</div>
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block">Created By</div>
|
||||
<div class="text-center">Actions</div>
|
||||
</div>
|
||||
|
||||
<!-- New Brand Form Row -->
|
||||
@if (selectedBrand && !selectedBrand.id) {
|
||||
<div class="brand-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-4 md:px-8">
|
||||
<div class="truncate">
|
||||
<span class="text-xs font-medium text-blue-600">Auto-generated</span>
|
||||
</div>
|
||||
<div class="truncate font-medium text-blue-600">New Brand</div>
|
||||
<div class="hidden truncate md:block text-blue-600 text-sm">-</div>
|
||||
<div class="hidden truncate lg:block text-blue-600 text-sm">-</div>
|
||||
<div class="hidden sm:block text-blue-600 text-sm">-</div>
|
||||
<!-- ⭐ NEW: Created By (Auto) -->
|
||||
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>Auto</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button mat-icon-button (click)="closeDetails()" matTooltip="Close">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:x-mark'"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Brand Form Details -->
|
||||
<div class="grid">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: selectedBrand }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Existing Brands -->
|
||||
@for (brand of brands; track trackByFn($index, brand)) {
|
||||
<div class="brand-grid grid items-center gap-4 border-b px-6 py-4 hover:bg-gray-50 transition-colors md:px-8">
|
||||
<!-- Brand ID -->
|
||||
<div class="truncate">
|
||||
<span class="text-xs font-mono text-blue-600 font-semibold">{{ brand.brand_id }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="truncate">
|
||||
<div class="font-semibold text-gray-900">{{ brand.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Branch ID -->
|
||||
<div class="hidden truncate md:block">
|
||||
<div class="text-sm text-gray-600">{{ brand.branch_id }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Branch Name -->
|
||||
<div class="hidden truncate lg:block">
|
||||
<div class="text-sm text-gray-600">{{ brand.branch_name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="hidden sm:flex items-center">
|
||||
@if (brand.image) {
|
||||
<img [src]="getFullImageUrl(brand.image)" [alt]="brand.name"
|
||||
class="h-10 w-10 rounded object-cover border">
|
||||
} @else {
|
||||
<div class="h-10 w-10 rounded bg-gray-100 flex items-center justify-center border">
|
||||
<mat-icon class="icon-size-5 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>{{ brand.created_by_username || 'N/A' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button mat-icon-button (click)="toggleDetails(brand.id)"
|
||||
[matTooltip]="selectedBrand?.id === brand.id ? 'Hide Details' : 'View Details'">
|
||||
<mat-icon class="icon-size-5 text-gray-600" [svgIcon]="
|
||||
selectedBrand?.id === brand.id
|
||||
? 'heroicons_outline:chevron-up'
|
||||
: 'heroicons_outline:chevron-down'
|
||||
"></mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="editBrand(brand)" matTooltip="Edit">
|
||||
<mat-icon class="icon-size-5 text-blue-600" [svgIcon]="'heroicons_outline:pencil'"></mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="deleteBrand(brand)" matTooltip="Delete">
|
||||
<mat-icon class="icon-size-5 text-red-600" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand Details -->
|
||||
@if (selectedBrand?.id === brand.id) {
|
||||
<div class="grid">
|
||||
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: brand }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
|
||||
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
|
||||
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
|
||||
[showFirstLastButtons]="true"></mat-paginator>
|
||||
} @else {
|
||||
<!-- Empty State -->
|
||||
<div class="flex flex-col items-center justify-center p-16">
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 mb-6">
|
||||
<mat-icon class="icon-size-16 text-gray-400" [svgIcon]="'heroicons_outline:tag'"></mat-icon>
|
||||
</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-900 mb-2">No brands found</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">Get started by creating your first brand</p>
|
||||
<button mat-flat-button [color]="'primary'" (click)="createBrand()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||
<span class="ml-2">Add Brand</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Row Details Template -->
|
||||
<ng-template #rowDetailsTemplate let-brand>
|
||||
<div class="overflow-hidden bg-white border-b">
|
||||
<div class="flex">
|
||||
<form class="flex w-full flex-col" [formGroup]="selectedBrandForm">
|
||||
<div class="p-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 pb-6 border-b">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
{{ brand.id ? 'Edit Brand' : 'Create New Brand' }}
|
||||
</h2>
|
||||
@if (brand.brand_id) {
|
||||
<p class="text-sm text-gray-500 mt-1">Brand ID: <span class="font-mono text-blue-600">{{ brand.brand_id }}</span></p>
|
||||
} @else {
|
||||
<p class="text-sm text-gray-500 mt-1">Brand ID will be auto-generated from name (first 3 letters + 4-digit sequence)</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Brand Name -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Brand Name</mat-label>
|
||||
<input matInput [formControlName]="'name'" placeholder="e.g., Coca-Cola"
|
||||
(input)="onNameChange($event)" />
|
||||
<mat-error *ngIf="selectedBrandForm.get('name')?.hasError('required')">
|
||||
Name is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="selectedBrandForm.get('name')?.hasError('minlength')">
|
||||
Minimum 2 characters required
|
||||
</mat-error>
|
||||
<mat-hint>ID will be: {{ previewBrandId }}</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Branch Selection -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch</mat-label>
|
||||
<mat-select [formControlName]="'branch_id'" (selectionChange)="onBranchChange($event)">
|
||||
<mat-option *ngFor="let branch of branches" [value]="branch.branch_id">
|
||||
{{ branch.name }} ({{ branch.branch_id }})
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="selectedBrandForm.get('branch_id')?.hasError('required')">
|
||||
Branch is required
|
||||
</mat-error>
|
||||
<mat-hint>Branch will be auto-filled</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Auto-filled Branch ID (readonly) -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch ID</mat-label>
|
||||
<input matInput [value]="selectedBranchId" readonly />
|
||||
<mat-hint>Auto-filled from branch selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Auto-filled Branch Name (readonly) -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch Name</mat-label>
|
||||
<input matInput [value]="selectedBranchName" readonly />
|
||||
<mat-hint>Auto-filled from branch selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- ⭐ NEW: Created By Field (Read-only) -->
|
||||
@if (brand.id && brand.created_by_username) {
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Created By</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="brand.created_by_username"
|
||||
readonly
|
||||
/>
|
||||
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Brand Image Upload -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Brand Image (Optional)</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Image Preview -->
|
||||
<div class="flex-shrink-0">
|
||||
@if (imagePreview || brand.image) {
|
||||
<img [src]="imagePreview || getFullImageUrl(brand.image)" [alt]="brand.name"
|
||||
class="h-24 w-24 rounded-lg object-cover border-2 border-gray-200">
|
||||
} @else {
|
||||
<div class="h-24 w-24 rounded-lg bg-gray-100 flex items-center justify-center border-2 border-dashed border-gray-300">
|
||||
<mat-icon class="icon-size-8 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<div class="flex-1">
|
||||
<label class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
<mat-icon class="icon-size-5 mr-2" [svgIcon]="'heroicons_outline:cloud-arrow-up'"></mat-icon>
|
||||
Upload Image
|
||||
<input type="file" accept="image/*" class="hidden" (change)="onImageSelected($event)">
|
||||
</label>
|
||||
<p class="mt-2 text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
|
||||
@if (imagePreview || brand.image) {
|
||||
<button type="button" mat-button color="warn" class="mt-2" (click)="removeImage()">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||
Remove Image
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
@if (brand.id) {
|
||||
<div class="mt-6 pt-6 border-t">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Created:</span>
|
||||
<span class="ml-2 text-gray-900">{{ brand.created_at | date:'medium' }}</span>
|
||||
</div>
|
||||
@if (brand.updated_at && brand.updated_at !== brand.created_at) {
|
||||
<div>
|
||||
<span class="text-gray-500">Last Updated:</span>
|
||||
<span class="ml-2 text-gray-900">{{ brand.updated_at | date:'medium' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex w-full items-center justify-between border-t bg-gray-50 px-8 py-4">
|
||||
<button mat-button [color]="'warn'" (click)="deleteSelectedBrand()"
|
||||
type="button" [disabled]="!brand.id">
|
||||
Delete
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (flashMessage) {
|
||||
<div class="flex items-center text-sm">
|
||||
@if (flashMessage === 'success') {
|
||||
<mat-icon class="icon-size-5 text-green-600 mr-2"
|
||||
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
|
||||
<span class="text-green-600">{{ brand.id ? 'Updated' : 'Created' }} successfully</span>
|
||||
}
|
||||
@if (flashMessage === 'error') {
|
||||
<mat-icon class="icon-size-5 text-red-600 mr-2"
|
||||
[svgIcon]="'heroicons_outline:x-circle'"></mat-icon>
|
||||
<span class="text-red-600">Error occurred</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button mat-stroked-button (click)="closeDetails()" type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button mat-flat-button [color]="'primary'" (click)="updateSelectedBrand()"
|
||||
type="button" [disabled]="selectedBrandForm.invalid || isLoading">
|
||||
@if (isLoading) {
|
||||
<mat-icon class="icon-size-5 animate-spin mr-2" [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
|
||||
}
|
||||
<span>{{ brand.id ? 'Update' : 'Create' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,397 @@
|
||||
/* Brand grid layout - NOW 7 COLUMNS WITH CREATED_BY */
|
||||
.brand-grid {
|
||||
// Brand ID, Name, Branch ID, Branch Name, Image, Created By, Actions (7 columns)
|
||||
grid-template-columns: 120px 1fr 120px 150px 80px 150px 120px;
|
||||
|
||||
@media (max-width: 1279px) { // xl breakpoint - hide Created By
|
||||
grid-template-columns: 120px 1fr 120px 150px 80px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) { // lg breakpoint - hide branch name
|
||||
grid-template-columns: 120px 1fr 120px 80px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) { // md breakpoint - hide branch ID
|
||||
grid-template-columns: 120px 1fr 80px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) { // sm breakpoint - hide image
|
||||
grid-template-columns: 120px 1fr 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Created By Column Styling */
|
||||
.brand-grid > div:nth-child(6) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.brand-grid > div:nth-child(6) mat-icon {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.brand-grid > div:nth-child(6) span {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .brand-grid > div:nth-child(6) mat-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .brand-grid > div:nth-child(6) span {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Material form field customizations */
|
||||
.fuse-mat-dense {
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.fuse-mat-rounded {
|
||||
.mat-mdc-form-field-flex {
|
||||
border-radius: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.mat-focused .mat-mdc-form-field-flex {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon size utilities */
|
||||
.icon-size-5 {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon-size-8 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.icon-size-16 {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
/* Brand image styling */
|
||||
.brand-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.brand-image-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Image preview styling */
|
||||
.image-preview-container {
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Upload button styling */
|
||||
.upload-button {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Minimal button styles */
|
||||
mat-icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
mat-icon {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover mat-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Form field minimal styling */
|
||||
mat-form-field {
|
||||
&.mat-mdc-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Brand row hover effect */
|
||||
.brand-grid > div:not(.sticky):not(.bg-blue-50) {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state styling */
|
||||
.empty-state {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.brand-grid {
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
mat-form-field {
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.fuse-mat-rounded .mat-mdc-form-field-flex {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.fuse-mat-rounded.mat-focused .mat-mdc-form-field-flex {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.brand-grid > div:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
.brand-image,
|
||||
.brand-image-placeholder {
|
||||
border-color: #374151;
|
||||
background-color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar - minimal */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* Focus states for accessibility */
|
||||
.mat-mdc-form-field.mat-focused .mat-mdc-form-field-flex {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Clean transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Pagination styling - minimal */
|
||||
mat-paginator {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Brand ID badge styling */
|
||||
.font-mono {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Clean form appearance */
|
||||
.mat-mdc-form-field-appearance-outline {
|
||||
.mat-mdc-form-field-outline {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&.mat-focused .mat-mdc-form-field-outline {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Minimal hint text */
|
||||
.mat-mdc-form-field-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Error state - clean */
|
||||
.mat-mdc-form-field-error {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Button height consistency */
|
||||
.mat-mdc-raised-button,
|
||||
.mat-mdc-outlined-button {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
/* Clean dividers */
|
||||
.border-b {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Typography - clean hierarchy */
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Minimal shadow on details panel */
|
||||
.overflow-hidden {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Clean action button group */
|
||||
.flex.gap-1 {
|
||||
button {
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth hover transitions */
|
||||
.transition-colors {
|
||||
transition-property: background-color, color;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
/* Clean badge for brand ID */
|
||||
.text-blue-600 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Minimal progress bar */
|
||||
mat-progress-bar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Clean tooltip */
|
||||
.mat-mdc-tooltip {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* Responsive typography */
|
||||
@media (max-width: 640px) {
|
||||
.text-4xl {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Clean focus ring */
|
||||
button:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Minimal snackbar */
|
||||
.snackbar-success {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.snackbar-error {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
/* Image upload area */
|
||||
.image-upload-area {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Preview badge style */
|
||||
.preview-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background-color: #eff6ff;
|
||||
color: #1e40af;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
@ -1,12 +1,463 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSortModule, MatSort } from '@angular/material/sort';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, map, switchMap, takeUntil } from 'rxjs';
|
||||
import { BrandService, Brand } from './brand.service';
|
||||
import { BranchService, Branch } from '../../company/branch-list/branch.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-brand',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
MatProgressBarModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
templateUrl: './brand.component.html',
|
||||
styleUrl: './brand.component.scss'
|
||||
})
|
||||
export class BrandComponent {
|
||||
export class BrandComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
|
||||
}
|
||||
// Observables
|
||||
brands$: Observable<Brand[]>;
|
||||
private brandsSubject = new BehaviorSubject<Brand[]>([]);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Form controls
|
||||
searchInputControl = new FormControl('');
|
||||
selectedBrandForm!: FormGroup;
|
||||
|
||||
// State management
|
||||
selectedBrand: Brand | null = null;
|
||||
isLoading = false;
|
||||
flashMessage: 'success' | 'error' | null = null;
|
||||
|
||||
// Pagination
|
||||
pagination = {
|
||||
length: 0,
|
||||
page: 0,
|
||||
size: 10
|
||||
};
|
||||
|
||||
// Branches for dropdown
|
||||
branches: Branch[] = [];
|
||||
|
||||
// Auto-fill fields
|
||||
selectedBranchId = '';
|
||||
selectedBranchName = '';
|
||||
previewBrandId = 'XXX0001';
|
||||
|
||||
// Image handling
|
||||
imagePreview: string | null = null;
|
||||
selectedImageFile: File | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private brandService: BrandService,
|
||||
private branchService: BranchService,
|
||||
private snackBar: MatSnackBar
|
||||
) {
|
||||
this.brands$ = this.brandsSubject.asObservable();
|
||||
this.initializeForm();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBranches();
|
||||
this.loadBrands();
|
||||
this.setupSearch();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the brand form
|
||||
*/
|
||||
private initializeForm(): void {
|
||||
this.selectedBrandForm = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
branch_id: ['', [Validators.required]]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup search functionality with debounce
|
||||
*/
|
||||
private setupSearch(): void {
|
||||
this.searchInputControl.valueChanges
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((searchTerm) => {
|
||||
this.filterBrands(searchTerm || '');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all branches for dropdown
|
||||
*/
|
||||
loadBranches(): void {
|
||||
this.branchService.getAllBranches()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (branches) => {
|
||||
this.branches = branches;
|
||||
console.log('Branches loaded:', branches.length);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading branches:', error);
|
||||
this.showSnackBar('Failed to load branches', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all brands from the backend
|
||||
*/
|
||||
loadBrands(): void {
|
||||
this.isLoading = true;
|
||||
|
||||
this.brandService.getAllBrands()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (brands) => {
|
||||
this.brandsSubject.next(brands);
|
||||
this.pagination.length = brands.length;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading brands:', error);
|
||||
this.showSnackBar('Failed to load brands', 'error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter brands based on search term
|
||||
*/
|
||||
private filterBrands(searchTerm: string): void {
|
||||
this.brandService.getAllBrands()
|
||||
.pipe(
|
||||
map(brands => {
|
||||
if (!searchTerm) {
|
||||
return brands;
|
||||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return brands.filter(brand =>
|
||||
brand.name.toLowerCase().includes(term) ||
|
||||
brand.brand_id.toLowerCase().includes(term) ||
|
||||
brand.branch_name.toLowerCase().includes(term)
|
||||
);
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe(filtered => {
|
||||
this.brandsSubject.next(filtered);
|
||||
this.pagination.length = filtered.length;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview brand ID generation based on name
|
||||
*/
|
||||
onNameChange(event: any): void {
|
||||
const name = event.target.value;
|
||||
if (name) {
|
||||
this.previewBrandId = this.generateBrandIdPreview(name);
|
||||
} else {
|
||||
this.previewBrandId = 'XXX0001';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate brand ID preview (frontend simulation)
|
||||
*/
|
||||
private generateBrandIdPreview(name: string): string {
|
||||
const lettersOnly = name.replace(/[^a-zA-Z]/g, '').toUpperCase();
|
||||
const prefix = lettersOnly.substring(0, 3).padEnd(3, 'X');
|
||||
return `${prefix}0001`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle branch selection change
|
||||
*/
|
||||
onBranchChange(event: any): void {
|
||||
const branchId = event.value;
|
||||
const branch = this.branches.find(b => b.branch_id === branchId);
|
||||
|
||||
if (branch) {
|
||||
this.selectedBranchId = branch.branch_id;
|
||||
this.selectedBranchName = branch.name;
|
||||
} else {
|
||||
this.selectedBranchId = '';
|
||||
this.selectedBranchName = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle image selection
|
||||
*/
|
||||
onImageSelected(event: any): void {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
// Validate file size (5MB max)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
this.showSnackBar('Image size must be less than 5MB', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.showSnackBar('Please select an image file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedImageFile = file;
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: any) => {
|
||||
this.imagePreview = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove selected image
|
||||
*/
|
||||
removeImage(): void {
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
|
||||
if (this.selectedBrand?.id) {
|
||||
this.selectedBrand.image = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full image URL
|
||||
*/
|
||||
getFullImageUrl(imagePath: string | null): string {
|
||||
if (!imagePath) return '';
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
return `http://localhost:5000${imagePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new brand - open form
|
||||
*/
|
||||
createBrand(): void {
|
||||
this.selectedBrand = {
|
||||
id: 0,
|
||||
brand_id: '',
|
||||
name: '',
|
||||
branch_id: '',
|
||||
branch_name: '',
|
||||
image: null,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
};
|
||||
|
||||
this.selectedBrandForm.reset();
|
||||
this.selectedBranchId = '';
|
||||
this.selectedBranchName = '';
|
||||
this.previewBrandId = 'XXX0001';
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit existing brand
|
||||
*/
|
||||
editBrand(brand: Brand): void {
|
||||
this.selectedBrand = { ...brand };
|
||||
this.selectedBrandForm.patchValue({
|
||||
name: brand.name,
|
||||
branch_id: brand.branch_id
|
||||
});
|
||||
|
||||
this.selectedBranchId = brand.branch_id;
|
||||
this.selectedBranchName = brand.branch_name;
|
||||
this.previewBrandId = brand.brand_id;
|
||||
this.imagePreview = brand.image ? this.getFullImageUrl(brand.image) : null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle brand details view
|
||||
*/
|
||||
toggleDetails(brandId: number): void {
|
||||
if (this.selectedBrand?.id === brandId) {
|
||||
this.closeDetails();
|
||||
} else {
|
||||
const brand = this.brandsSubject.value.find(b => b.id === brandId);
|
||||
if (brand) {
|
||||
this.editBrand(brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close details panel
|
||||
*/
|
||||
closeDetails(): void {
|
||||
this.selectedBrand = null;
|
||||
this.selectedBrandForm.reset();
|
||||
this.selectedBranchId = '';
|
||||
this.selectedBranchName = '';
|
||||
this.previewBrandId = 'XXX0001';
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create brand
|
||||
*/
|
||||
updateSelectedBrand(): void {
|
||||
if (this.selectedBrandForm.invalid) {
|
||||
this.selectedBrandForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
// Prepare form data
|
||||
const formData = new FormData();
|
||||
formData.append('name', this.selectedBrandForm.get('name')?.value);
|
||||
formData.append('branch_id', this.selectedBrandForm.get('branch_id')?.value);
|
||||
|
||||
// Add image if selected
|
||||
if (this.selectedImageFile) {
|
||||
formData.append('image', this.selectedImageFile);
|
||||
}
|
||||
|
||||
const operation = this.selectedBrand?.id
|
||||
? this.brandService.updateBrand(this.selectedBrand.id, formData)
|
||||
: this.brandService.createBrand(formData);
|
||||
|
||||
operation
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (brand) => {
|
||||
this.flashMessage = 'success';
|
||||
this.isLoading = false;
|
||||
this.showSnackBar(
|
||||
this.selectedBrand?.id ? 'Brand updated successfully' : 'Brand created successfully',
|
||||
'success'
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
this.closeDetails();
|
||||
this.loadBrands();
|
||||
}, 1500);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error saving brand:', error);
|
||||
this.flashMessage = 'error';
|
||||
this.isLoading = false;
|
||||
this.showSnackBar(
|
||||
error.error?.error || 'Failed to save brand',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected brand
|
||||
*/
|
||||
deleteSelectedBrand(): void {
|
||||
if (!this.selectedBrand?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete brand "${this.selectedBrand.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deleteBrand(this.selectedBrand);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete brand
|
||||
*/
|
||||
deleteBrand(brand: Brand): void {
|
||||
if (!confirm(`Are you sure you want to delete brand "${brand.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
this.brandService.deleteBrand(brand.id)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.showSnackBar('Brand deleted successfully', 'success');
|
||||
this.closeDetails();
|
||||
this.loadBrands();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error deleting brand:', error);
|
||||
this.showSnackBar(
|
||||
error.error?.error || 'Failed to delete brand',
|
||||
'error'
|
||||
);
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track by function for ngFor
|
||||
*/
|
||||
trackByFn(index: number, item: Brand): number {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show snackbar notification
|
||||
*/
|
||||
private showSnackBar(message: string, type: 'success' | 'error'): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 3000,
|
||||
horizontalPosition: 'end',
|
||||
verticalPosition: 'top',
|
||||
panelClass: type === 'success' ? 'snackbar-success' : 'snackbar-error'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { environment } from '../../../../../../environments/environment';
|
||||
|
||||
export interface Brand {
|
||||
id: number;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
branch_id: string;
|
||||
branch_name: string;
|
||||
image: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// ⭐ NEW: Created by fields (automatic from backend)
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BrandService {
|
||||
private apiUrl = `${environment.apiUrl}/brands`;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/**
|
||||
* Get authorization headers
|
||||
*/
|
||||
private getHeaders(): HttpHeaders {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
return new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all brands
|
||||
*/
|
||||
getAllBrands(): Observable<Brand[]> {
|
||||
return this.http.get<Brand[]>(this.apiUrl, { headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single brand by ID
|
||||
*/
|
||||
getBrand(id: number): Observable<Brand> {
|
||||
return this.http.get<Brand>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new brand (with FormData for image upload)
|
||||
* ⭐ NOTE: Don't add created_by to FormData
|
||||
* Backend automatically extracts it from JWT token
|
||||
*/
|
||||
createBrand(formData: FormData): Observable<Brand> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
// Don't set Content-Type for FormData - browser sets it automatically with boundary
|
||||
});
|
||||
|
||||
return this.http.post<Brand>(this.apiUrl, formData, { headers })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing brand (with FormData for image upload)
|
||||
* ⭐ NOTE: Don't update created_by
|
||||
* It's set once during creation and shouldn't change
|
||||
*/
|
||||
updateBrand(id: number, formData: FormData): Observable<Brand> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
// Don't set Content-Type for FormData - browser sets it automatically with boundary
|
||||
});
|
||||
|
||||
return this.http.put<Brand>(`${this.apiUrl}/${id}`, formData, { headers })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete brand
|
||||
*/
|
||||
deleteBrand(id: number): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search brands by query
|
||||
*/
|
||||
searchBrands(query: string): Observable<Brand[]> {
|
||||
return this.http.get<Brand[]>(`${this.apiUrl}/search?q=${encodeURIComponent(query)}`,
|
||||
{ headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brands by branch
|
||||
*/
|
||||
getBrandsByBranch(branchId: string): Observable<Brand[]> {
|
||||
return this.http.get<Brand[]>(`${this.apiUrl}/by-branch/${branchId}`,
|
||||
{ headers: this.getHeaders() })
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP errors
|
||||
*/
|
||||
private handleError(error: any): Observable<never> {
|
||||
console.error('API Error:', error);
|
||||
|
||||
let errorMessage = 'An error occurred';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Client-side error
|
||||
errorMessage = error.error.message;
|
||||
} else {
|
||||
// Server-side error
|
||||
errorMessage = error.error?.error || error.message || 'Server error';
|
||||
}
|
||||
|
||||
return throwError(() => ({
|
||||
error: errorMessage,
|
||||
status: error.status
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1 +1,329 @@
|
||||
<p>categories works!</p>
|
||||
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
|
||||
@if (isLoading) {
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
|
||||
</div>
|
||||
}
|
||||
<div class="text-4xl font-extrabold tracking-tight">Category Management</div>
|
||||
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
|
||||
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
|
||||
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
|
||||
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
|
||||
[placeholder]="'Search categories'" />
|
||||
</mat-form-field>
|
||||
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="createCategory()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||
<span class="ml-2 mr-1">Add Category</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex flex-auto overflow-hidden">
|
||||
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
|
||||
@if (categories$ | async; as categories) {
|
||||
@if (categories.length > 0 || selectedCategory) {
|
||||
<div class="grid">
|
||||
<!-- Header -->
|
||||
<div class="category-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-sm font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
|
||||
matSort matSortDisableClear>
|
||||
<div [mat-sort-header]="'category_id'">Category ID</div>
|
||||
<div [mat-sort-header]="'name'">Name</div>
|
||||
<div class="hidden sm:block">Image</div>
|
||||
<div class="hidden md:block" [mat-sort-header]="'brand_name'">Brand</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'branch_name'">Branch</div>
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block">Created By</div>
|
||||
<div class="text-center">Actions</div>
|
||||
</div>
|
||||
|
||||
<!-- New Category Form Row -->
|
||||
@if (selectedCategory && !selectedCategory.id) {
|
||||
<div class="category-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-4 md:px-8">
|
||||
<div class="truncate"><span class="text-xs font-medium text-blue-600">Auto-generated</span></div>
|
||||
<div class="truncate font-medium text-blue-600">New Category</div>
|
||||
<div class="hidden sm:block text-blue-600 text-sm">-</div>
|
||||
<div class="hidden md:block text-blue-600 text-sm">-</div>
|
||||
<div class="hidden lg:block text-blue-600 text-sm">-</div>
|
||||
<!-- ⭐ NEW: Created By (Auto) -->
|
||||
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>Auto</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button mat-icon-button (click)="closeDetails()" matTooltip="Close">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:x-mark'"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: selectedCategory }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Existing Categories -->
|
||||
@for (category of categories; track trackByFn($index, category)) {
|
||||
<div class="category-grid grid items-center gap-4 border-b px-6 py-4 hover:bg-gray-50 transition-colors md:px-8">
|
||||
<div class="truncate">
|
||||
<span class="text-xs font-mono text-blue-600 font-semibold">{{ category.category_id }}</span>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<div class="font-semibold text-gray-900">{{ category.name }}</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center">
|
||||
@if (category.image) {
|
||||
<img [src]="getFullImageUrl(category.image)" [alt]="category.name"
|
||||
class="h-10 w-10 rounded object-cover border">
|
||||
} @else {
|
||||
<div class="h-10 w-10 rounded bg-gray-100 flex items-center justify-center border">
|
||||
<mat-icon class="icon-size-5 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="hidden md:block truncate">
|
||||
<div class="text-sm text-gray-600">{{ category.brand_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ category.brand_id }}</div>
|
||||
</div>
|
||||
<div class="hidden lg:block truncate">
|
||||
<div class="text-sm text-gray-600">{{ category.branch_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ category.branch_id }}</div>
|
||||
</div>
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>{{ category.created_by_username || 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button mat-icon-button (click)="toggleDetails(category.id)"
|
||||
[matTooltip]="selectedCategory?.id === category.id ? 'Hide Details' : 'View Details'">
|
||||
<mat-icon class="icon-size-5 text-gray-600" [svgIcon]="
|
||||
selectedCategory?.id === category.id
|
||||
? 'heroicons_outline:chevron-up'
|
||||
: 'heroicons_outline:chevron-down'
|
||||
"></mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="editCategory(category)" matTooltip="Edit">
|
||||
<mat-icon class="icon-size-5 text-blue-600" [svgIcon]="'heroicons_outline:pencil'"></mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="deleteCategory(category)" matTooltip="Delete">
|
||||
<mat-icon class="icon-size-5 text-red-600" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedCategory?.id === category.id) {
|
||||
<div class="grid">
|
||||
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: category }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
|
||||
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
|
||||
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
|
||||
[showFirstLastButtons]="true"></mat-paginator>
|
||||
} @else {
|
||||
<div class="flex flex-col items-center justify-center p-16">
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 mb-6">
|
||||
<mat-icon class="icon-size-16 text-gray-400" [svgIcon]="'heroicons_outline:squares-2x2'"></mat-icon>
|
||||
</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-900 mb-2">No categories found</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">Get started by creating your first category</p>
|
||||
<button mat-flat-button [color]="'primary'" (click)="createCategory()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||
<span class="ml-2">Add Category</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Row Details Template -->
|
||||
<ng-template #rowDetailsTemplate let-category>
|
||||
<div class="overflow-hidden bg-white border-b">
|
||||
<div class="flex">
|
||||
<form class="flex w-full flex-col" [formGroup]="selectedCategoryForm">
|
||||
<div class="p-8">
|
||||
<div class="mb-6 pb-6 border-b">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
{{ category.id ? 'Edit Category' : 'Create New Category' }}
|
||||
</h2>
|
||||
@if (category.category_id) {
|
||||
<p class="text-sm text-gray-500 mt-1">Category ID: <span class="font-mono text-blue-600">{{ category.category_id }}</span></p>
|
||||
} @else {
|
||||
<p class="text-sm text-gray-500 mt-1">Category ID will be auto-generated from name</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Category Name -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Category Name</mat-label>
|
||||
<input matInput [formControlName]="'name'" placeholder="e.g., Beverages"
|
||||
(input)="onNameChange($event)" />
|
||||
<mat-error *ngIf="selectedCategoryForm.get('name')?.hasError('required')">
|
||||
Name is required
|
||||
</mat-error>
|
||||
<mat-hint>ID will be: {{ previewCategoryId }}</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Branch Selection -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch</mat-label>
|
||||
<mat-select [formControlName]="'branch_id'" (selectionChange)="onBranchChange($event)">
|
||||
<mat-option *ngFor="let branch of branches" [value]="branch.branch_id">
|
||||
{{ branch.name }} ({{ branch.code }})
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="selectedCategoryForm.get('branch_id')?.hasError('required')">
|
||||
Branch is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Auto-filled Branch ID -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch ID</mat-label>
|
||||
<input matInput [value]="selectedBranchId" readonly />
|
||||
<mat-hint>Auto-filled from branch selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Auto-filled Branch Name -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch Name</mat-label>
|
||||
<input matInput [value]="selectedBranchName" readonly />
|
||||
<mat-hint>Auto-filled from branch selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Brand Selection (filtered by branch) -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Brand</mat-label>
|
||||
<mat-select [formControlName]="'brand_id'" (selectionChange)="onBrandChange($event)"
|
||||
[disabled]="!selectedBranchId">
|
||||
@if (!selectedBranchId) {
|
||||
<mat-option disabled>Select branch first</mat-option>
|
||||
}
|
||||
<mat-option *ngFor="let brand of filteredBrands" [value]="brand.brand_id">
|
||||
{{ brand.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="selectedCategoryForm.get('brand_id')?.hasError('required')">
|
||||
Brand is required
|
||||
</mat-error>
|
||||
<mat-hint>Brands filtered by selected branch</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Auto-filled Brand ID -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Brand ID</mat-label>
|
||||
<input matInput [value]="selectedBrandId" readonly />
|
||||
<mat-hint>Auto-filled from brand selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- ⭐ NEW: Created By Field (Read-only) -->
|
||||
@if (category.id && category.created_by_username) {
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Created By</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="category.created_by_username"
|
||||
readonly
|
||||
/>
|
||||
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Category Image Upload -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Category Image (Optional)</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
@if (imagePreview || category.image) {
|
||||
<img [src]="imagePreview || getFullImageUrl(category.image)" [alt]="category.name"
|
||||
class="h-24 w-24 rounded-lg object-cover border-2 border-gray-200">
|
||||
} @else {
|
||||
<div class="h-24 w-24 rounded-lg bg-gray-100 flex items-center justify-center border-2 border-dashed border-gray-300">
|
||||
<mat-icon class="icon-size-8 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
<mat-icon class="icon-size-5 mr-2" [svgIcon]="'heroicons_outline:cloud-arrow-up'"></mat-icon>
|
||||
Upload Image
|
||||
<input type="file" accept="image/*" class="hidden" (change)="onImageSelected($event)">
|
||||
</label>
|
||||
<p class="mt-2 text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
|
||||
@if (imagePreview || category.image) {
|
||||
<button type="button" mat-button color="warn" class="mt-2" (click)="removeImage()">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||
Remove Image
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (category.id) {
|
||||
<div class="mt-6 pt-6 border-t">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Created:</span>
|
||||
<span class="ml-2 text-gray-900">{{ category.created_at | date:'medium' }}</span>
|
||||
</div>
|
||||
@if (category.updated_at && category.updated_at !== category.created_at) {
|
||||
<div>
|
||||
<span class="text-gray-500">Last Updated:</span>
|
||||
<span class="ml-2 text-gray-900">{{ category.updated_at | date:'medium' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex w-full items-center justify-between border-t bg-gray-50 px-8 py-4">
|
||||
<button mat-button [color]="'warn'" (click)="deleteSelectedCategory()"
|
||||
type="button" [disabled]="!category.id">
|
||||
Delete
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (flashMessage) {
|
||||
<div class="flex items-center text-sm">
|
||||
@if (flashMessage === 'success') {
|
||||
<mat-icon class="icon-size-5 text-green-600 mr-2"
|
||||
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
|
||||
<span class="text-green-600">{{ category.id ? 'Updated' : 'Created' }} successfully</span>
|
||||
}
|
||||
@if (flashMessage === 'error') {
|
||||
<mat-icon class="icon-size-5 text-red-600 mr-2"
|
||||
[svgIcon]="'heroicons_outline:x-circle'"></mat-icon>
|
||||
<span class="text-red-600">Error occurred</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button mat-stroked-button (click)="closeDetails()" type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button mat-flat-button [color]="'primary'" (click)="updateSelectedCategory()"
|
||||
type="button" [disabled]="selectedCategoryForm.invalid || isLoading">
|
||||
@if (isLoading) {
|
||||
<mat-icon class="icon-size-5 animate-spin mr-2" [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
|
||||
}
|
||||
<span>{{ category.id ? 'Update' : 'Create' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,291 @@
|
||||
/* Category grid layout - NOW 7 COLUMNS WITH CREATED_BY */
|
||||
.category-grid {
|
||||
// Category ID, Name, Image, Brand, Branch, Created By, Actions (7 columns)
|
||||
grid-template-columns: 120px 1fr 80px 150px 150px 150px 120px;
|
||||
|
||||
@media (max-width: 1279px) { // xl breakpoint - hide Created By
|
||||
grid-template-columns: 120px 1fr 80px 150px 150px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) { // lg - hide branch
|
||||
grid-template-columns: 120px 1fr 80px 150px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) { // md - hide brand
|
||||
grid-template-columns: 120px 1fr 80px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) { // sm - hide image
|
||||
grid-template-columns: 120px 1fr 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Created By Column Styling */
|
||||
.category-grid > div:nth-child(6) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.category-grid > div:nth-child(6) mat-icon {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.category-grid > div:nth-child(6) span {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .category-grid > div:nth-child(6) mat-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .category-grid > div:nth-child(6) span {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Material form field customizations */
|
||||
.fuse-mat-dense {
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.fuse-mat-rounded {
|
||||
.mat-mdc-form-field-flex {
|
||||
border-radius: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.mat-focused .mat-mdc-form-field-flex {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon sizes */
|
||||
.icon-size-5 {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon-size-8 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.icon-size-16 {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
/* Minimal button styles */
|
||||
mat-icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
mat-icon {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover mat-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Form field minimal styling */
|
||||
mat-form-field {
|
||||
&.mat-mdc-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Row hover effect */
|
||||
.category-grid > div:not(.sticky):not(.bg-blue-50) {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.category-grid {
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
mat-form-field .mat-mdc-text-field-wrapper {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.fuse-mat-rounded .mat-mdc-form-field-flex {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.category-grid > div:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.mat-mdc-form-field.mat-focused .mat-mdc-form-field-flex {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Clean transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
mat-paginator {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.font-mono {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Form appearance */
|
||||
.mat-mdc-form-field-appearance-outline {
|
||||
.mat-mdc-form-field-outline {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&.mat-focused .mat-mdc-form-field-outline {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hints and errors */
|
||||
.mat-mdc-form-field-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-error {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Button heights */
|
||||
.mat-mdc-raised-button,
|
||||
.mat-mdc-outlined-button {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
/* Dividers */
|
||||
.border-b, .border-t {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Typography hierarchy */
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Shadows */
|
||||
.overflow-hidden {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.flex.gap-1 button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.transition-colors {
|
||||
transition-property: background-color, color;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
.text-blue-600 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
mat-progress-bar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.mat-mdc-tooltip {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* Responsive typography */
|
||||
@media (max-width: 640px) {
|
||||
.text-4xl {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus ring */
|
||||
button:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Snackbar */
|
||||
.snackbar-success {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.snackbar-error {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
@ -1,12 +1,337 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSortModule, MatSort } from '@angular/material/sort';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs';
|
||||
import { CategoryService, Category } from './category.service';
|
||||
import { BranchService, Branch } from '../../company/branch-list/branch.service';
|
||||
import { BrandService, Brand } from '../brand/brand.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-categories',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [
|
||||
CommonModule, ReactiveFormsModule, MatPaginatorModule, MatSortModule,
|
||||
MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule,
|
||||
MatIconModule, MatTooltipModule, MatProgressBarModule, MatSnackBarModule
|
||||
],
|
||||
templateUrl: './categories.component.html',
|
||||
styleUrl: './categories.component.scss'
|
||||
})
|
||||
export class CategoriesComponent {
|
||||
export class CategoriesComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
|
||||
}
|
||||
categories$: Observable<Category[]>;
|
||||
private categoriesSubject = new BehaviorSubject<Category[]>([]);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
searchInputControl = new FormControl('');
|
||||
selectedCategoryForm!: FormGroup;
|
||||
selectedCategory: Category | null = null;
|
||||
isLoading = false;
|
||||
flashMessage: 'success' | 'error' | null = null;
|
||||
|
||||
pagination = { length: 0, page: 0, size: 10 };
|
||||
|
||||
branches: Branch[] = [];
|
||||
brands: Brand[] = [];
|
||||
filteredBrands: Brand[] = [];
|
||||
|
||||
selectedBranchId = '';
|
||||
selectedBranchName = '';
|
||||
selectedBrandId = '';
|
||||
previewCategoryId = 'XXX0001';
|
||||
|
||||
imagePreview: string | null = null;
|
||||
selectedImageFile: File | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private categoryService: CategoryService,
|
||||
private branchService: BranchService,
|
||||
private brandService: BrandService,
|
||||
private snackBar: MatSnackBar
|
||||
) {
|
||||
this.categories$ = this.categoriesSubject.asObservable();
|
||||
this.initializeForm();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBranches();
|
||||
this.loadBrands();
|
||||
this.loadCategories();
|
||||
this.setupSearch();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeForm(): void {
|
||||
this.selectedCategoryForm = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
branch_id: ['', [Validators.required]],
|
||||
brand_id: ['', [Validators.required]]
|
||||
});
|
||||
}
|
||||
|
||||
private setupSearch(): void {
|
||||
this.searchInputControl.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe((searchTerm) => {
|
||||
this.filterCategories(searchTerm || '');
|
||||
});
|
||||
}
|
||||
|
||||
loadBranches(): void {
|
||||
this.branchService.getAllBranches().pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (branches) => { this.branches = branches; },
|
||||
error: (error) => this.showSnackBar('Failed to load branches', 'error')
|
||||
});
|
||||
}
|
||||
|
||||
loadBrands(): void {
|
||||
this.brandService.getAllBrands().pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (brands) => { this.brands = brands; },
|
||||
error: (error) => this.showSnackBar('Failed to load brands', 'error')
|
||||
});
|
||||
}
|
||||
|
||||
loadCategories(): void {
|
||||
this.isLoading = true;
|
||||
this.categoryService.getAllCategories().pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (categories) => {
|
||||
this.categoriesSubject.next(categories);
|
||||
this.pagination.length = categories.length;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (error) => {
|
||||
this.showSnackBar('Failed to load categories', 'error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private filterCategories(searchTerm: string): void {
|
||||
this.categoryService.getAllCategories().pipe(
|
||||
map(categories => {
|
||||
if (!searchTerm) return categories;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return categories.filter(cat =>
|
||||
cat.name.toLowerCase().includes(term) ||
|
||||
cat.category_id.toLowerCase().includes(term) ||
|
||||
cat.brand_name.toLowerCase().includes(term)
|
||||
);
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(filtered => {
|
||||
this.categoriesSubject.next(filtered);
|
||||
this.pagination.length = filtered.length;
|
||||
});
|
||||
}
|
||||
|
||||
onNameChange(event: any): void {
|
||||
const name = event.target.value;
|
||||
this.previewCategoryId = name ? this.generateCategoryIdPreview(name) : 'XXX0001';
|
||||
}
|
||||
|
||||
private generateCategoryIdPreview(name: string): string {
|
||||
const lettersOnly = name.replace(/[^a-zA-Z]/g, '').toUpperCase();
|
||||
const prefix = lettersOnly.substring(0, 3).padEnd(3, 'X');
|
||||
return `${prefix}0001`;
|
||||
}
|
||||
|
||||
onBranchChange(event: any): void {
|
||||
const branchId = event.value;
|
||||
const branch = this.branches.find(b => b.branch_id === branchId);
|
||||
|
||||
if (branch) {
|
||||
this.selectedBranchId = branch.branch_id;
|
||||
this.selectedBranchName = branch.name;
|
||||
this.filteredBrands = this.brands.filter(b => b.branch_id === branchId);
|
||||
this.selectedCategoryForm.patchValue({ brand_id: '' });
|
||||
this.selectedBrandId = '';
|
||||
}
|
||||
}
|
||||
|
||||
onBrandChange(event: any): void {
|
||||
const brandId = event.value;
|
||||
const brand = this.brands.find(b => b.brand_id === brandId);
|
||||
this.selectedBrandId = brand ? brand.brand_id : '';
|
||||
}
|
||||
|
||||
onImageSelected(event: any): void {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
this.showSnackBar('Image size must be less than 5MB', 'error');
|
||||
return;
|
||||
}
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.showSnackBar('Please select an image file', 'error');
|
||||
return;
|
||||
}
|
||||
this.selectedImageFile = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: any) => { this.imagePreview = e.target.result; };
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
removeImage(): void {
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
if (this.selectedCategory?.id) this.selectedCategory.image = null;
|
||||
}
|
||||
|
||||
getFullImageUrl(imagePath: string | null): string {
|
||||
if (!imagePath) return '';
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
return `http://localhost:5000${imagePath}`;
|
||||
}
|
||||
|
||||
createCategory(): void {
|
||||
this.selectedCategory = {
|
||||
id: 0, category_id: '', name: '', image: null,
|
||||
brand_id: '', brand_name: '', branch_id: '', branch_name: '',
|
||||
created_at: '', updated_at: ''
|
||||
};
|
||||
this.selectedCategoryForm.reset();
|
||||
this.selectedBranchId = '';
|
||||
this.selectedBranchName = '';
|
||||
this.selectedBrandId = '';
|
||||
this.filteredBrands = [];
|
||||
this.previewCategoryId = 'XXX0001';
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
editCategory(category: Category): void {
|
||||
this.selectedCategory = { ...category };
|
||||
this.selectedCategoryForm.patchValue({
|
||||
name: category.name,
|
||||
branch_id: category.branch_id,
|
||||
brand_id: category.brand_id
|
||||
});
|
||||
this.selectedBranchId = category.branch_id;
|
||||
this.selectedBranchName = category.branch_name;
|
||||
this.selectedBrandId = category.brand_id;
|
||||
this.filteredBrands = this.brands.filter(b => b.branch_id === category.branch_id);
|
||||
this.previewCategoryId = category.category_id;
|
||||
this.imagePreview = category.image ? this.getFullImageUrl(category.image) : null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
toggleDetails(categoryId: number): void {
|
||||
if (this.selectedCategory?.id === categoryId) {
|
||||
this.closeDetails();
|
||||
} else {
|
||||
const category = this.categoriesSubject.value.find(c => c.id === categoryId);
|
||||
if (category) this.editCategory(category);
|
||||
}
|
||||
}
|
||||
|
||||
closeDetails(): void {
|
||||
this.selectedCategory = null;
|
||||
this.selectedCategoryForm.reset();
|
||||
this.selectedBranchId = '';
|
||||
this.selectedBranchName = '';
|
||||
this.selectedBrandId = '';
|
||||
this.filteredBrands = [];
|
||||
this.previewCategoryId = 'XXX0001';
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
updateSelectedCategory(): void {
|
||||
if (this.selectedCategoryForm.invalid) {
|
||||
this.selectedCategoryForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('name', this.selectedCategoryForm.get('name')?.value);
|
||||
formData.append('branch_id', this.selectedCategoryForm.get('branch_id')?.value);
|
||||
formData.append('brand_id', this.selectedCategoryForm.get('brand_id')?.value);
|
||||
if (this.selectedImageFile) formData.append('image', this.selectedImageFile);
|
||||
|
||||
const operation = this.selectedCategory?.id
|
||||
? this.categoryService.updateCategory(this.selectedCategory.id, formData)
|
||||
: this.categoryService.createCategory(formData);
|
||||
|
||||
operation.pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (category) => {
|
||||
this.flashMessage = 'success';
|
||||
this.isLoading = false;
|
||||
this.showSnackBar(
|
||||
this.selectedCategory?.id ? 'Category updated successfully' : 'Category created successfully',
|
||||
'success'
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.closeDetails();
|
||||
this.loadCategories();
|
||||
}, 1500);
|
||||
},
|
||||
error: (error) => {
|
||||
this.flashMessage = 'error';
|
||||
this.isLoading = false;
|
||||
this.showSnackBar(error.error?.error || 'Failed to save category', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteSelectedCategory(): void {
|
||||
if (!this.selectedCategory?.id) return;
|
||||
if (!confirm(`Are you sure you want to delete category "${this.selectedCategory.name}"?`)) return;
|
||||
this.deleteCategory(this.selectedCategory);
|
||||
}
|
||||
|
||||
deleteCategory(category: Category): void {
|
||||
if (!confirm(`Are you sure you want to delete category "${category.name}"?`)) return;
|
||||
this.isLoading = true;
|
||||
this.categoryService.deleteCategory(category.id).pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.showSnackBar('Category deleted successfully', 'success');
|
||||
this.closeDetails();
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (error) => {
|
||||
this.showSnackBar(error.error?.error || 'Failed to delete category', 'error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
trackByFn(index: number, item: Category): number {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
private showSnackBar(message: string, type: 'success' | 'error'): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 3000,
|
||||
horizontalPosition: 'end',
|
||||
verticalPosition: 'top',
|
||||
panelClass: type === 'success' ? 'snackbar-success' : 'snackbar-error'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { environment } from '../../../../../../environments/environment';
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
category_id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
brand_id: string;
|
||||
brand_name: string;
|
||||
branch_id: string;
|
||||
branch_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// ⭐ NEW: Created by fields (automatic from backend)
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CategoryService {
|
||||
private apiUrl = `${environment.apiUrl}/categories`;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private getHeaders(): HttpHeaders {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
return new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
});
|
||||
}
|
||||
|
||||
getAllCategories(): Observable<Category[]> {
|
||||
return this.http.get<Category[]>(this.apiUrl, { headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getCategory(id: number): Observable<Category> {
|
||||
return this.http.get<Category>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new category (with FormData for image upload)
|
||||
* ⭐ NOTE: Don't add created_by to FormData
|
||||
* Backend automatically extracts it from JWT token
|
||||
*/
|
||||
createCategory(formData: FormData): Observable<Category> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
});
|
||||
return this.http.post<Category>(this.apiUrl, formData, { headers })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing category (with FormData for image upload)
|
||||
* ⭐ NOTE: Don't update created_by
|
||||
* It's set once during creation and shouldn't change
|
||||
*/
|
||||
updateCategory(id: number, formData: FormData): Observable<Category> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
});
|
||||
return this.http.put<Category>(`${this.apiUrl}/${id}`, formData, { headers })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteCategory(id: number): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
searchCategories(query: string): Observable<Category[]> {
|
||||
return this.http.get<Category[]>(`${this.apiUrl}/search?q=${encodeURIComponent(query)}`,
|
||||
{ headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getCategoriesByBrand(brandId: string): Observable<Category[]> {
|
||||
return this.http.get<Category[]>(`${this.apiUrl}/by-brand/${brandId}`,
|
||||
{ headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getCategoriesByBranch(branchId: string): Observable<Category[]> {
|
||||
return this.http.get<Category[]>(`${this.apiUrl}/by-branch/${branchId}`,
|
||||
{ headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
private handleError(error: any): Observable<never> {
|
||||
console.error('API Error:', error);
|
||||
let errorMessage = 'An error occurred';
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
errorMessage = error.error.message;
|
||||
} else {
|
||||
errorMessage = error.error?.error || error.message || 'Server error';
|
||||
}
|
||||
return throwError(() => ({ error: errorMessage, status: error.status }));
|
||||
}
|
||||
}
|
||||
@ -54,7 +54,7 @@
|
||||
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
|
||||
@if (products && products.length > 0 || showAddForm) {
|
||||
<div class="grid">
|
||||
<!-- Header -->
|
||||
<!-- Header - NOW WITH 9 COLUMNS -->
|
||||
<div
|
||||
class="inventory-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-md font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
|
||||
matSort
|
||||
@ -64,19 +64,19 @@
|
||||
<div [mat-sort-header]="'product_id'">Product ID</div>
|
||||
<div [mat-sort-header]="'product_name'">Product Name</div>
|
||||
<div class="hidden sm:block" [mat-sort-header]="'price'">Price</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'billing_date'">Billing Date</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'expiration_date'">Expiration Date</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'created_date'">Created Date</div>
|
||||
<div class="hidden xl:block">Created By</div>
|
||||
<div class="hidden sm:block">Details</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Product Form (when toggled) -->
|
||||
<!-- Add Product Form -->
|
||||
@if (showAddForm) {
|
||||
<div class="grid">
|
||||
<div class="inventory-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-3 md:px-8 dark:bg-blue-900 dark:bg-opacity-20">
|
||||
<!-- Empty space for image column -->
|
||||
<div></div>
|
||||
<!-- Auto-generated ID placeholder -->
|
||||
<div class="text-gray-500 italic">Auto-generated</div>
|
||||
<!-- Product name input -->
|
||||
<div>
|
||||
<mat-form-field class="w-full fuse-mat-dense">
|
||||
<input
|
||||
@ -87,7 +87,6 @@
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<!-- Price input -->
|
||||
<div class="hidden sm:block">
|
||||
<mat-form-field class="w-full fuse-mat-dense">
|
||||
<span matPrefix>₹</span>
|
||||
@ -100,11 +99,15 @@
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<!-- Current date display -->
|
||||
<div class="hidden lg:block text-gray-500">-</div>
|
||||
<div class="hidden lg:block text-gray-500">-</div>
|
||||
<div class="hidden lg:block text-gray-500">
|
||||
{{ currentDate | date : 'fullDate' }}
|
||||
</div>
|
||||
<!-- Action buttons -->
|
||||
<div class="hidden xl:block text-gray-500 flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>Auto</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
mat-icon-button
|
||||
@ -205,6 +208,37 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Billing and Expiration Date Row -->
|
||||
<div class="flex gap-4">
|
||||
<mat-form-field class="flex-1">
|
||||
<mat-label>Billing Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="billingPicker"
|
||||
formControlName="billing_date"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="billingPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #billingPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-1">
|
||||
<mat-label>Expiration Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="expirationPicker"
|
||||
formControlName="expiration_date"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="expirationPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #expirationPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Created By Info -->
|
||||
<div class="mt-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:information-circle'"></mat-icon>
|
||||
<span>Creator will be set automatically from your account</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -263,6 +297,10 @@
|
||||
<div class="grid">
|
||||
<div
|
||||
class="inventory-grid grid items-center gap-4 border-b px-6 py-3 md:px-8"
|
||||
[ngClass]="{
|
||||
'bg-red-50 dark:bg-red-900 dark:bg-opacity-20': isExpired(product),
|
||||
'bg-yellow-50 dark:bg-yellow-900 dark:bg-opacity-20': isExpiringSoon(product) && !isExpired(product)
|
||||
}"
|
||||
>
|
||||
<!-- Image -->
|
||||
<div class="flex items-center">
|
||||
@ -287,21 +325,78 @@
|
||||
</div>
|
||||
|
||||
<!-- Product ID -->
|
||||
<div class="truncate">{{ product.product_id }}</div>
|
||||
<div class="truncate" [ngClass]="{
|
||||
'text-red-700 dark:text-red-300 font-semibold': isExpired(product),
|
||||
'text-yellow-700 dark:text-yellow-300 font-semibold': isExpiringSoon(product) && !isExpired(product)
|
||||
}">{{ product.product_id }}</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="truncate">{{ product.product_name }}</div>
|
||||
<div class="truncate" [ngClass]="{
|
||||
'text-red-700 dark:text-red-300 font-semibold': isExpired(product),
|
||||
'text-yellow-700 dark:text-yellow-300 font-semibold': isExpiringSoon(product) && !isExpired(product)
|
||||
}">{{ product.product_name }}</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="hidden sm:block">
|
||||
<div class="hidden sm:block" [ngClass]="{
|
||||
'text-red-700 dark:text-red-300 font-semibold': isExpired(product),
|
||||
'text-yellow-700 dark:text-yellow-300 font-semibold': isExpiringSoon(product) && !isExpired(product)
|
||||
}">
|
||||
{{ product.price | currency : 'INR' : 'symbol' : '1.2-2' }}
|
||||
</div>
|
||||
|
||||
<!-- Created Date -->
|
||||
<!-- Billing Date -->
|
||||
<div class="hidden lg:block" [ngClass]="{
|
||||
'text-red-700 dark:text-red-300': isExpired(product),
|
||||
'text-yellow-700 dark:text-yellow-300': isExpiringSoon(product) && !isExpired(product)
|
||||
}">
|
||||
@if (product.billing_date) {
|
||||
{{ product.billing_date | date : 'mediumDate' }}
|
||||
} @else {
|
||||
<span class="text-gray-400">N/A</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Expiration Date -->
|
||||
<div class="hidden lg:block">
|
||||
@if (product.expiration_date) {
|
||||
<div class="flex items-center gap-2">
|
||||
@if (isExpired(product)) {
|
||||
<mat-icon class="icon-size-4 text-red-600 dark:text-red-400" [svgIcon]="'heroicons_outline:exclamation-circle'"></mat-icon>
|
||||
<span class="text-red-600 dark:text-red-400 font-semibold">
|
||||
{{ product.expiration_date | date : 'mediumDate' }}
|
||||
</span>
|
||||
} @else if (isExpiringSoon(product)) {
|
||||
<mat-icon class="icon-size-4 text-yellow-600 dark:text-yellow-400" [svgIcon]="'heroicons_outline:exclamation-triangle'"></mat-icon>
|
||||
<span class="text-yellow-600 dark:text-yellow-400 font-semibold">
|
||||
{{ product.expiration_date | date : 'mediumDate' }}
|
||||
</span>
|
||||
<span class="text-xs text-yellow-600 dark:text-yellow-400">({{ getDaysUntilExpiration(product) }} days)</span>
|
||||
} @else {
|
||||
<span>{{ product.expiration_date | date : 'mediumDate' }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="text-gray-400">N/A</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Created Date -->
|
||||
<div class="hidden lg:block" [ngClass]="{
|
||||
'text-red-700 dark:text-red-300': isExpired(product),
|
||||
'text-yellow-700 dark:text-yellow-300': isExpiringSoon(product) && !isExpired(product)
|
||||
}">
|
||||
{{ product.created_date | date : 'fullDate' }}
|
||||
</div>
|
||||
|
||||
<!-- Created By -->
|
||||
<div class="hidden xl:block flex items-center gap-1" [ngClass]="{
|
||||
'text-red-700 dark:text-red-300': isExpired(product),
|
||||
'text-yellow-700 dark:text-yellow-300': isExpiringSoon(product) && !isExpired(product)
|
||||
}">
|
||||
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>{{ product.created_by_username || 'N/A' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Details button -->
|
||||
<div>
|
||||
<button
|
||||
@ -422,6 +517,63 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Billing and Expiration Date Row -->
|
||||
<div class="flex gap-4">
|
||||
<mat-form-field class="flex-1">
|
||||
<mat-label>Billing Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="editBillingPicker"
|
||||
formControlName="billing_date"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="editBillingPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #editBillingPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-1">
|
||||
<mat-label>Expiration Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="editExpirationPicker"
|
||||
formControlName="expiration_date"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="editExpirationPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #editExpirationPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Expiration Warning -->
|
||||
@if (isExpiringSoon(product) || isExpired(product)) {
|
||||
<div class="mt-2 flex items-center gap-2 p-3 rounded"
|
||||
[ngClass]="{
|
||||
'bg-red-100 text-red-700 dark:bg-red-900 dark:bg-opacity-30 dark:text-red-200': isExpired(product),
|
||||
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:bg-opacity-30 dark:text-yellow-200': isExpiringSoon(product) && !isExpired(product)
|
||||
}">
|
||||
<mat-icon class="icon-size-5"
|
||||
[svgIcon]="isExpired(product) ? 'heroicons_outline:exclamation-circle' : 'heroicons_outline:exclamation-triangle'"></mat-icon>
|
||||
<span class="font-medium">
|
||||
@if (isExpired(product)) {
|
||||
This product has expired!
|
||||
} @else {
|
||||
This product expires in {{ getDaysUntilExpiration(product) }} days!
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Created By - Display only (read-only) -->
|
||||
@if (product.created_by_username) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>Created By</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="product.created_by_username"
|
||||
readonly
|
||||
/>
|
||||
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
/* Product Management Component Styles */
|
||||
/* Product Management Component Styles - UPDATED FOR 9 COLUMNS */
|
||||
|
||||
/* Grid Layout for Product List */
|
||||
/* Grid Layout for Product List - NOW 9 COLUMNS */
|
||||
.inventory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 120px 1fr 140px 200px 80px;
|
||||
grid-template-columns: 80px 120px 1fr 140px 140px 140px 160px 150px 80px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
@ -15,18 +15,129 @@
|
||||
}
|
||||
|
||||
/* Responsive Grid Adjustments */
|
||||
@media (max-width: 1400px) {
|
||||
/* Hide Created By column on smaller screens */
|
||||
.inventory-grid {
|
||||
grid-template-columns: 80px 120px 1fr 140px 140px 140px 160px 80px;
|
||||
}
|
||||
|
||||
.inventory-grid > div:nth-child(8) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
/* Hide date columns on medium screens */
|
||||
.inventory-grid {
|
||||
grid-template-columns: 80px 120px 1fr 140px 80px;
|
||||
}
|
||||
|
||||
.inventory-grid > div:nth-child(5),
|
||||
.inventory-grid > div:nth-child(6),
|
||||
.inventory-grid > div:nth-child(7) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Minimal columns on small screens */
|
||||
.inventory-grid {
|
||||
grid-template-columns: 80px 1fr 120px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Created By Column Styling */
|
||||
.inventory-grid > div:nth-child(8) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.inventory-grid > div:nth-child(8) mat-icon {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.inventory-grid > div:nth-child(8) span {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .inventory-grid > div:nth-child(8) mat-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .inventory-grid > div:nth-child(8) span {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Expiration Warning Styles */
|
||||
.bg-red-50 {
|
||||
background-color: rgba(254, 226, 226, 0.5) !important;
|
||||
}
|
||||
|
||||
.bg-yellow-50 {
|
||||
background-color: rgba(254, 249, 195, 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .bg-red-900 {
|
||||
background-color: rgba(127, 29, 29, 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .bg-yellow-900 {
|
||||
background-color: rgba(120, 53, 15, 0.2) !important;
|
||||
}
|
||||
|
||||
.text-red-600 {
|
||||
color: rgb(220, 38, 38) !important;
|
||||
}
|
||||
|
||||
.text-yellow-600 {
|
||||
color: rgb(202, 138, 4) !important;
|
||||
}
|
||||
|
||||
.text-red-700 {
|
||||
color: rgb(185, 28, 28) !important;
|
||||
}
|
||||
|
||||
.text-yellow-700 {
|
||||
color: rgb(161, 98, 7) !important;
|
||||
}
|
||||
|
||||
.dark .text-red-300 {
|
||||
color: rgb(252, 165, 165) !important;
|
||||
}
|
||||
|
||||
.dark .text-yellow-300 {
|
||||
color: rgb(253, 224, 71) !important;
|
||||
}
|
||||
|
||||
.dark .text-red-400 {
|
||||
color: rgb(248, 113, 113) !important;
|
||||
}
|
||||
|
||||
.dark .text-yellow-400 {
|
||||
color: rgb(250, 204, 21) !important;
|
||||
}
|
||||
|
||||
.dark .text-red-200 {
|
||||
color: rgb(254, 202, 202) !important;
|
||||
}
|
||||
|
||||
.dark .text-yellow-200 {
|
||||
color: rgb(254, 240, 138) !important;
|
||||
}
|
||||
|
||||
.bg-red-100 {
|
||||
background-color: rgb(254, 226, 226);
|
||||
}
|
||||
|
||||
.bg-yellow-100 {
|
||||
background-color: rgb(254, 249, 195);
|
||||
}
|
||||
|
||||
/* Product Image Styles */
|
||||
.product-image-container {
|
||||
position: relative;
|
||||
@ -385,4 +496,54 @@ button[mat-stroked-button] {
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Date Field Styling */
|
||||
.mat-mdc-form-field {
|
||||
.mat-datepicker-toggle {
|
||||
color: #1976d2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Expiration Warning Box */
|
||||
.expiration-warning {
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.expired {
|
||||
background-color: rgb(254, 226, 226);
|
||||
color: rgb(185, 28, 28);
|
||||
border: 1px solid rgb(252, 165, 165);
|
||||
}
|
||||
|
||||
&.expiring-soon {
|
||||
background-color: rgb(254, 249, 195);
|
||||
color: rgb(161, 98, 7);
|
||||
border: 1px solid rgb(253, 224, 71);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.expiration-warning {
|
||||
&.expired {
|
||||
background-color: rgba(127, 29, 29, 0.3);
|
||||
color: rgb(254, 202, 202);
|
||||
border-color: rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
&.expiring-soon {
|
||||
background-color: rgba(120, 53, 15, 0.3);
|
||||
color: rgb(254, 240, 138);
|
||||
border-color: rgba(250, 204, 21, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
// src/app/products/product-list/product-list.component.ts
|
||||
import { Component, OnInit, ViewChild, Input } from '@angular/core';
|
||||
import { Product, ProductService } from '../product.service';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
@ -12,7 +13,9 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { environment } from '@environments/environment';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { environment } from '@environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-list',
|
||||
@ -33,7 +36,9 @@ import { environment } from '@environments/environment';
|
||||
MatFormFieldModule,
|
||||
MatProgressBarModule,
|
||||
MatRippleModule,
|
||||
MatTooltipModule
|
||||
MatTooltipModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule
|
||||
]
|
||||
})
|
||||
export class ProductListComponent implements OnInit {
|
||||
@ -84,14 +89,18 @@ export class ProductListComponent implements OnInit {
|
||||
price: ['', [Validators.required, Validators.min(0)]],
|
||||
created_date: [{ value: new Date(), disabled: true }],
|
||||
product_id: [{ value: null, disabled: true }],
|
||||
category: ['']
|
||||
category: [''],
|
||||
billing_date: [''],
|
||||
expiration_date: ['']
|
||||
});
|
||||
|
||||
// New add product form (inline)
|
||||
this.addProductForm = this.formBuilder.group({
|
||||
product_name: ['', Validators.required],
|
||||
price: [0, [Validators.required, Validators.min(0.01)]],
|
||||
category: ['']
|
||||
category: [''],
|
||||
billing_date: [''],
|
||||
expiration_date: ['']
|
||||
});
|
||||
|
||||
this.searchControl.valueChanges.subscribe(query => {
|
||||
@ -178,6 +187,58 @@ export class ProductListComponent implements OnInit {
|
||||
product.product_image = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product is expiring within 3 days
|
||||
*/
|
||||
isExpiringSoon(product: Product): boolean {
|
||||
if (!product.expiration_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expirationDate = new Date(product.expiration_date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const threeDaysFromNow = new Date();
|
||||
threeDaysFromNow.setDate(today.getDate() + 3);
|
||||
threeDaysFromNow.setHours(23, 59, 59, 999);
|
||||
|
||||
return expirationDate <= threeDaysFromNow && expirationDate >= today;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product is expired
|
||||
*/
|
||||
isExpired(product: Product): boolean {
|
||||
if (!product.expiration_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expirationDate = new Date(product.expiration_date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return expirationDate < today;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days until expiration
|
||||
*/
|
||||
getDaysUntilExpiration(product: Product): number {
|
||||
if (!product.expiration_date) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const expirationDate = new Date(product.expiration_date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = expirationDate.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return diffDays;
|
||||
}
|
||||
|
||||
toggleDetails(productId: string): void {
|
||||
if (this.selectedProduct?.product_id === productId) {
|
||||
this.selectedProduct = null;
|
||||
@ -185,31 +246,33 @@ export class ProductListComponent implements OnInit {
|
||||
const prod = this.products.find(p => p.product_id === productId) || null;
|
||||
this.selectedProduct = prod;
|
||||
if (prod) {
|
||||
// Ensure we have the numeric id for updates
|
||||
console.log('Selected product for editing:', prod); // Debug log
|
||||
console.log('Selected product for editing:', prod);
|
||||
this.productForm.patchValue({
|
||||
product_name: prod.product_name,
|
||||
price: prod.price,
|
||||
created_date: prod.created_date,
|
||||
product_id: prod.product_id,
|
||||
category: prod.category
|
||||
category: prod.category,
|
||||
billing_date: prod.billing_date ? new Date(prod.billing_date) : null,
|
||||
expiration_date: prod.expiration_date ? new Date(prod.expiration_date) : null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline Add Product Methods (replaces modal)
|
||||
// Inline Add Product Methods
|
||||
toggleAddProduct(): void {
|
||||
if (this.showAddForm) {
|
||||
this.cancelAddProduct();
|
||||
} else {
|
||||
// Close any open product details
|
||||
this.selectedProduct = null;
|
||||
this.showAddForm = true;
|
||||
this.addProductForm.reset({
|
||||
product_name: '',
|
||||
price: 0,
|
||||
category: ''
|
||||
category: '',
|
||||
billing_date: '',
|
||||
expiration_date: ''
|
||||
});
|
||||
this.addProductImagePreview = null;
|
||||
this.addProductSelectedFile = null;
|
||||
@ -253,6 +316,18 @@ export class ProductListComponent implements OnInit {
|
||||
if (category) {
|
||||
formData.append('category', category);
|
||||
}
|
||||
|
||||
// Add billing date
|
||||
const billingDate = this.addProductForm.get('billing_date')?.value;
|
||||
if (billingDate) {
|
||||
formData.append('billing_date', this.formatDate(billingDate));
|
||||
}
|
||||
|
||||
// Add expiration date
|
||||
const expirationDate = this.addProductForm.get('expiration_date')?.value;
|
||||
if (expirationDate) {
|
||||
formData.append('expiration_date', this.formatDate(expirationDate));
|
||||
}
|
||||
|
||||
if (this.addProductSelectedFile) {
|
||||
formData.append('product_image', this.addProductSelectedFile);
|
||||
@ -263,11 +338,12 @@ export class ProductListComponent implements OnInit {
|
||||
this.isAddingProduct = false;
|
||||
this.showAddFlashMessage('success');
|
||||
this.loadProducts();
|
||||
// Reset form but keep it open for potential additional entries
|
||||
this.addProductForm.reset({
|
||||
product_name: '',
|
||||
price: 0,
|
||||
category: ''
|
||||
category: '',
|
||||
billing_date: '',
|
||||
expiration_date: ''
|
||||
});
|
||||
this.addProductImagePreview = null;
|
||||
this.addProductSelectedFile = null;
|
||||
@ -306,7 +382,7 @@ export class ProductListComponent implements OnInit {
|
||||
updateProduct(): void {
|
||||
if (!this.selectedProduct?.id) {
|
||||
console.error('No product selected for update or missing numeric ID');
|
||||
console.log('Selected product:', this.selectedProduct); // Debug log
|
||||
console.log('Selected product:', this.selectedProduct);
|
||||
this.showFlashMessage('error');
|
||||
return;
|
||||
}
|
||||
@ -326,6 +402,18 @@ export class ProductListComponent implements OnInit {
|
||||
if (category) {
|
||||
formData.append('category', category);
|
||||
}
|
||||
|
||||
// Add billing date
|
||||
const billingDate = this.productForm.get('billing_date')?.value;
|
||||
if (billingDate) {
|
||||
formData.append('billing_date', this.formatDate(billingDate));
|
||||
}
|
||||
|
||||
// Add expiration date
|
||||
const expirationDate = this.productForm.get('expiration_date')?.value;
|
||||
if (expirationDate) {
|
||||
formData.append('expiration_date', this.formatDate(expirationDate));
|
||||
}
|
||||
|
||||
if (this.selectedFile) {
|
||||
formData.append('product_image', this.selectedFile);
|
||||
@ -350,7 +438,6 @@ export class ProductListComponent implements OnInit {
|
||||
}
|
||||
|
||||
deleteProduct(productId: string): void {
|
||||
// Find the product to get its numeric ID
|
||||
const product = this.products.find(p => p.product_id === productId);
|
||||
if (!product?.id) {
|
||||
console.error('Product not found or missing numeric ID:', productId);
|
||||
@ -383,4 +470,16 @@ export class ProductListComponent implements OnInit {
|
||||
this.selectedFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for backend (YYYY-MM-DD)
|
||||
*/
|
||||
private formatDate(date: Date | string): string {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
@ -1,39 +1,89 @@
|
||||
// src/app/products/product.service.ts
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap, catchError } from 'rxjs/operators';
|
||||
import { environment } from '@environments/environment';
|
||||
|
||||
export interface Product {
|
||||
id?: number; // Add this - the numeric primary key from backend
|
||||
product_id?: string; // Keep this - the string identifier like "PA020"
|
||||
id?: number;
|
||||
product_id?: string;
|
||||
product_name: string;
|
||||
price: number;
|
||||
product_image?: string | null;
|
||||
created_date: Date | string;
|
||||
category?: string;
|
||||
selected?: boolean; // For UI selection state
|
||||
|
||||
// NEW: Billing and expiration dates
|
||||
billing_date?: Date | string | null;
|
||||
expiration_date?: Date | string | null;
|
||||
|
||||
// Created by fields (automatic from backend)
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ProductService {
|
||||
private apiUrl = `${environment.apiUrl}/products`; // Replace with your actual API URL
|
||||
private apiUrl = environment.apiUrl2; // '/products' endpoint
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getProducts(): Observable<Product[]> {
|
||||
return this.http.get<Product[]>(this.apiUrl);
|
||||
return this.http.get<Product[]>(this.apiUrl).pipe(
|
||||
tap((products) => console.log('Fetched products:', products)),
|
||||
catchError((error) => {
|
||||
console.error('Error fetching products:', error);
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
addProduct(product: FormData): Observable<Product> {
|
||||
return this.http.post<Product>(this.apiUrl, product);
|
||||
// Backend automatically extracts created_by from JWT token
|
||||
return this.http.post<Product>(this.apiUrl, product).pipe(
|
||||
tap((newProduct) => console.log('Added product:', newProduct)),
|
||||
catchError((error) => {
|
||||
console.error('Error adding product:', error);
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateProduct(id: number, product: FormData): Observable<Product> {
|
||||
return this.http.put<Product>(`${this.apiUrl}/${id}`, product);
|
||||
return this.http.put<Product>(`${this.apiUrl}/${id}`, product).pipe(
|
||||
tap((updated) => console.log('Updated product:', updated)),
|
||||
catchError((error) => {
|
||||
console.error('Error updating product:', error);
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
deleteProduct(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(
|
||||
tap(() => console.log('Deleted product:', id)),
|
||||
catchError((error) => {
|
||||
console.error('Error deleting product:', error);
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get full image URL
|
||||
*/
|
||||
getImageUrl(imagePath: string | null | undefined): string {
|
||||
if (!imagePath) {
|
||||
return 'assets/images/product-placeholder.png';
|
||||
}
|
||||
|
||||
// Remove leading slash if present
|
||||
const cleanPath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
|
||||
|
||||
return `${environment.apiUrl}/${cleanPath}`;
|
||||
}
|
||||
}
|
||||
@ -1 +1,367 @@
|
||||
<p>sub-categories works!</p>
|
||||
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
|
||||
@if (isLoading) {
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
|
||||
</div>
|
||||
}
|
||||
<div class="text-4xl font-extrabold tracking-tight">SubCategory Management</div>
|
||||
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
|
||||
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
|
||||
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
|
||||
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
|
||||
[placeholder]="'Search sub-categories'" />
|
||||
</mat-form-field>
|
||||
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="createSubCategory()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||
<span class="ml-2 mr-1">Add SubCategory</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex flex-auto overflow-hidden">
|
||||
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
|
||||
@if (subCategories$ | async; as subCategories) {
|
||||
@if (subCategories.length > 0 || selectedSubCategory) {
|
||||
<div class="grid">
|
||||
<!-- Header -->
|
||||
<div class="subcategory-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-sm font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
|
||||
matSort matSortDisableClear>
|
||||
<div [mat-sort-header]="'sub_category_id'">SubCategory ID</div>
|
||||
<div [mat-sort-header]="'name'">Name</div>
|
||||
<div class="hidden sm:block">Image</div>
|
||||
<div class="hidden md:block" [mat-sort-header]="'category_name'">Category</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'brand_name'">Brand</div>
|
||||
<div class="hidden xl:block" [mat-sort-header]="'branch_name'">Branch</div>
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block">Created By</div>
|
||||
<div class="text-center">Actions</div>
|
||||
</div>
|
||||
|
||||
<!-- New SubCategory Form Row -->
|
||||
@if (selectedSubCategory && !selectedSubCategory.id) {
|
||||
<div class="subcategory-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-4 md:px-8">
|
||||
<div class="truncate"><span class="text-xs font-medium text-blue-600">Auto-generated</span></div>
|
||||
<div class="truncate font-medium text-blue-600">New SubCategory</div>
|
||||
<div class="hidden sm:block text-blue-600 text-sm">-</div>
|
||||
<div class="hidden md:block text-blue-600 text-sm">-</div>
|
||||
<div class="hidden lg:block text-blue-600 text-sm">-</div>
|
||||
<div class="hidden xl:block text-blue-600 text-sm">-</div>
|
||||
<!-- ⭐ NEW: Created By (Auto) -->
|
||||
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>Auto</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button mat-icon-button (click)="closeDetails()" matTooltip="Close">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:x-mark'"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: selectedSubCategory }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Existing SubCategories -->
|
||||
@for (subCategory of subCategories; track trackByFn($index, subCategory)) {
|
||||
<div class="subcategory-grid grid items-center gap-4 border-b px-6 py-4 hover:bg-gray-50 transition-colors md:px-8">
|
||||
<div class="truncate">
|
||||
<span class="text-xs font-mono text-blue-600 font-semibold">{{ subCategory.sub_category_id }}</span>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<div class="font-semibold text-gray-900">{{ subCategory.name }}</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center">
|
||||
@if (subCategory.image) {
|
||||
<img [src]="getFullImageUrl(subCategory.image)" [alt]="subCategory.name"
|
||||
class="h-10 w-10 rounded object-cover border">
|
||||
} @else {
|
||||
<div class="h-10 w-10 rounded bg-gray-100 flex items-center justify-center border">
|
||||
<mat-icon class="icon-size-5 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="hidden md:block truncate">
|
||||
<div class="text-sm text-gray-600">{{ subCategory.category_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ subCategory.category_id }}</div>
|
||||
</div>
|
||||
<div class="hidden lg:block truncate">
|
||||
<div class="text-sm text-gray-600">{{ subCategory.brand_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ subCategory.brand_id }}</div>
|
||||
</div>
|
||||
<div class="hidden xl:block truncate">
|
||||
<div class="text-sm text-gray-600">{{ subCategory.branch_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ subCategory.branch_id }}</div>
|
||||
</div>
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>{{ subCategory.created_by_username || 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button mat-icon-button (click)="toggleDetails(subCategory.id)"
|
||||
[matTooltip]="selectedSubCategory?.id === subCategory.id ? 'Hide Details' : 'View Details'">
|
||||
<mat-icon class="icon-size-5 text-gray-600" [svgIcon]="
|
||||
selectedSubCategory?.id === subCategory.id
|
||||
? 'heroicons_outline:chevron-up'
|
||||
: 'heroicons_outline:chevron-down'
|
||||
"></mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="editSubCategory(subCategory)" matTooltip="Edit">
|
||||
<mat-icon class="icon-size-5 text-blue-600" [svgIcon]="'heroicons_outline:pencil'"></mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="deleteSubCategory(subCategory)" matTooltip="Delete">
|
||||
<mat-icon class="icon-size-5 text-red-600" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedSubCategory?.id === subCategory.id) {
|
||||
<div class="grid">
|
||||
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: subCategory }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
|
||||
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
|
||||
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
|
||||
[showFirstLastButtons]="true"></mat-paginator>
|
||||
} @else {
|
||||
<div class="flex flex-col items-center justify-center p-16">
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 mb-6">
|
||||
<mat-icon class="icon-size-16 text-gray-400" [svgIcon]="'heroicons_outline:rectangle-group'"></mat-icon>
|
||||
</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-900 mb-2">No sub-categories found</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">Get started by creating your first sub-category</p>
|
||||
<button mat-flat-button [color]="'primary'" (click)="createSubCategory()">
|
||||
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||
<span class="ml-2">Add SubCategory</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Row Details Template -->
|
||||
<ng-template #rowDetailsTemplate let-subCategory>
|
||||
<div class="overflow-hidden bg-white border-b">
|
||||
<div class="flex">
|
||||
<form class="flex w-full flex-col" [formGroup]="selectedSubCategoryForm">
|
||||
<div class="p-8">
|
||||
<div class="mb-6 pb-6 border-b">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
{{ subCategory.id ? 'Edit SubCategory' : 'Create New SubCategory' }}
|
||||
</h2>
|
||||
@if (subCategory.sub_category_id) {
|
||||
<p class="text-sm text-gray-500 mt-1">SubCategory ID: <span class="font-mono text-blue-600">{{ subCategory.sub_category_id }}</span></p>
|
||||
} @else {
|
||||
<p class="text-sm text-gray-500 mt-1">SubCategory ID will be auto-generated from name</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 4-Level Cascading Form -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- SubCategory Name -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>SubCategory Name</mat-label>
|
||||
<input matInput [formControlName]="'name'" placeholder="e.g., Diet Coke"
|
||||
(input)="onNameChange($event)" />
|
||||
<mat-error *ngIf="selectedSubCategoryForm.get('name')?.hasError('required')">
|
||||
Name is required
|
||||
</mat-error>
|
||||
<mat-hint>ID will be: {{ previewSubCategoryId }}</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Level 1: Branch Selection -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch</mat-label>
|
||||
<mat-select [formControlName]="'branch_id'" (selectionChange)="onBranchChange($event)">
|
||||
<mat-option *ngFor="let branch of branches" [value]="branch.branch_id">
|
||||
{{ branch.name }} ({{ branch.code }})
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="selectedSubCategoryForm.get('branch_id')?.hasError('required')">
|
||||
Branch is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Branch ID (auto-fill) -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch ID</mat-label>
|
||||
<input matInput [value]="selectedBranchId" readonly />
|
||||
<mat-hint>Auto-filled from branch selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Branch Name (auto-fill) -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Branch Name</mat-label>
|
||||
<input matInput [value]="selectedBranchName" readonly />
|
||||
<mat-hint>Auto-filled from branch selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Level 2: Brand Selection -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Brand</mat-label>
|
||||
<mat-select [formControlName]="'brand_id'" (selectionChange)="onBrandChange($event)"
|
||||
[disabled]="!selectedBranchId">
|
||||
@if (!selectedBranchId) {
|
||||
<mat-option disabled>Select branch first</mat-option>
|
||||
}
|
||||
<mat-option *ngFor="let brand of filteredBrands" [value]="brand.brand_id">
|
||||
{{ brand.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="selectedSubCategoryForm.get('brand_id')?.hasError('required')">
|
||||
Brand is required
|
||||
</mat-error>
|
||||
<mat-hint>Brands filtered by selected branch</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Brand ID (auto-fill) -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Brand ID</mat-label>
|
||||
<input matInput [value]="selectedBrandId" readonly />
|
||||
<mat-hint>Auto-filled from brand selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Level 3: Category Selection -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Category</mat-label>
|
||||
<mat-select [formControlName]="'category_id'" (selectionChange)="onCategoryChange($event)"
|
||||
[disabled]="!selectedBrandId">
|
||||
@if (!selectedBrandId) {
|
||||
<mat-option disabled>Select brand first</mat-option>
|
||||
}
|
||||
<mat-option *ngFor="let category of filteredCategories" [value]="category.category_id">
|
||||
{{ category.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="selectedSubCategoryForm.get('category_id')?.hasError('required')">
|
||||
Category is required
|
||||
</mat-error>
|
||||
<mat-hint>Categories filtered by selected brand</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Category ID (auto-fill) -->
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Category ID</mat-label>
|
||||
<input matInput [value]="selectedCategoryId" readonly />
|
||||
<mat-hint>Auto-filled from category selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Category Name (auto-fill) -->
|
||||
<mat-form-field class="w-full md:col-span-2" appearance="outline">
|
||||
<mat-label>Category Name</mat-label>
|
||||
<input matInput [value]="selectedCategoryName" readonly />
|
||||
<mat-hint>Auto-filled from category selection</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- ⭐ NEW: Created By Field (Read-only) -->
|
||||
@if (subCategory.id && subCategory.created_by_username) {
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Created By</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="subCategory.created_by_username"
|
||||
readonly
|
||||
/>
|
||||
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- SubCategory Image Upload -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">SubCategory Image (Optional)</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
@if (imagePreview || subCategory.image) {
|
||||
<img [src]="imagePreview || getFullImageUrl(subCategory.image)" [alt]="subCategory.name"
|
||||
class="h-24 w-24 rounded-lg object-cover border-2 border-gray-200">
|
||||
} @else {
|
||||
<div class="h-24 w-24 rounded-lg bg-gray-100 flex items-center justify-center border-2 border-dashed border-gray-300">
|
||||
<mat-icon class="icon-size-8 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
<mat-icon class="icon-size-5 mr-2" [svgIcon]="'heroicons_outline:cloud-arrow-up'"></mat-icon>
|
||||
Upload Image
|
||||
<input type="file" accept="image/*" class="hidden" (change)="onImageSelected($event)">
|
||||
</label>
|
||||
<p class="mt-2 text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
|
||||
@if (imagePreview || subCategory.image) {
|
||||
<button type="button" mat-button color="warn" class="mt-2" (click)="removeImage()">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||
Remove Image
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (subCategory.id) {
|
||||
<div class="mt-6 pt-6 border-t">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Created:</span>
|
||||
<span class="ml-2 text-gray-900">{{ subCategory.created_at | date:'medium' }}</span>
|
||||
</div>
|
||||
@if (subCategory.updated_at && subCategory.updated_at !== subCategory.created_at) {
|
||||
<div>
|
||||
<span class="text-gray-500">Last Updated:</span>
|
||||
<span class="ml-2 text-gray-900">{{ subCategory.updated_at | date:'medium' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex w-full items-center justify-between border-t bg-gray-50 px-8 py-4">
|
||||
<button mat-button [color]="'warn'" (click)="deleteSelectedSubCategory()"
|
||||
type="button" [disabled]="!subCategory.id">
|
||||
Delete
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (flashMessage) {
|
||||
<div class="flex items-center text-sm">
|
||||
@if (flashMessage === 'success') {
|
||||
<mat-icon class="icon-size-5 text-green-600 mr-2"
|
||||
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
|
||||
<span class="text-green-600">{{ subCategory.id ? 'Updated' : 'Created' }} successfully</span>
|
||||
}
|
||||
@if (flashMessage === 'error') {
|
||||
<mat-icon class="icon-size-5 text-red-600 mr-2"
|
||||
[svgIcon]="'heroicons_outline:x-circle'"></mat-icon>
|
||||
<span class="text-red-600">Error occurred</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button mat-stroked-button (click)="closeDetails()" type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button mat-flat-button [color]="'primary'" (click)="updateSelectedSubCategory()"
|
||||
type="button" [disabled]="selectedSubCategoryForm.invalid || isLoading">
|
||||
@if (isLoading) {
|
||||
<mat-icon class="icon-size-5 animate-spin mr-2" [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
|
||||
}
|
||||
<span>{{ subCategory.id ? 'Update' : 'Create' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,180 @@
|
||||
/* SubCategory grid layout - NOW 8 COLUMNS WITH CREATED_BY */
|
||||
.subcategory-grid {
|
||||
// SubCategory ID, Name, Image, Category, Brand, Branch, Created By, Actions (8 columns)
|
||||
grid-template-columns: 120px 1fr 80px 150px 150px 150px 150px 120px;
|
||||
|
||||
@media (max-width: 1279px) { // xl breakpoint - hide Created By (changed from 2xl)
|
||||
grid-template-columns: 120px 1fr 80px 150px 150px 150px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) { // xl - hide branch
|
||||
grid-template-columns: 120px 1fr 80px 150px 150px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) { // lg - hide brand
|
||||
grid-template-columns: 120px 1fr 80px 150px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) { // md - hide category
|
||||
grid-template-columns: 120px 1fr 80px 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) { // sm - hide image
|
||||
grid-template-columns: 120px 1fr 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Created By Column Styling */
|
||||
.subcategory-grid > div:nth-child(7) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.subcategory-grid > div:nth-child(7) mat-icon {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.subcategory-grid > div:nth-child(7) span {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .subcategory-grid > div:nth-child(7) mat-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .subcategory-grid > div:nth-child(7) span {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.fuse-mat-dense {
|
||||
.mat-mdc-form-field-subscript-wrapper { display: none; }
|
||||
.mat-mdc-form-field-infix { min-height: 40px; }
|
||||
}
|
||||
|
||||
.fuse-mat-rounded {
|
||||
.mat-mdc-form-field-flex {
|
||||
border-radius: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border: none;
|
||||
}
|
||||
&.mat-focused .mat-mdc-form-field-flex {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-size-5 { width: 20px; height: 20px; font-size: 20px; }
|
||||
.icon-size-8 { width: 32px; height: 32px; font-size: 32px; }
|
||||
.icon-size-16 { width: 64px; height: 64px; font-size: 64px; }
|
||||
|
||||
mat-icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
mat-icon { transition: all 0.2s ease; }
|
||||
&:hover mat-icon { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
&.mat-mdc-form-field { width: 100%; }
|
||||
.mat-mdc-text-field-wrapper { background-color: #ffffff; }
|
||||
}
|
||||
|
||||
.subcategory-grid > div:not(.sticky):not(.bg-blue-50) {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.subcategory-grid { gap: 8px; padding: 12px 16px; }
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
mat-form-field .mat-mdc-text-field-wrapper {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.fuse-mat-rounded .mat-mdc-form-field-flex {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.subcategory-grid > div:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
|
||||
|
||||
.mat-mdc-form-field.mat-focused .mat-mdc-form-field-flex {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
* {
|
||||
transition-property: background-color, border-color, color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
mat-paginator { background-color: transparent; }
|
||||
.font-mono { letter-spacing: 0.05em; }
|
||||
|
||||
.mat-mdc-form-field-appearance-outline {
|
||||
.mat-mdc-form-field-outline { color: #e5e7eb; }
|
||||
&.mat-focused .mat-mdc-form-field-outline { color: #3b82f6; }
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-hint { font-size: 12px; color: #6b7280; }
|
||||
.mat-mdc-form-field-error { font-size: 12px; }
|
||||
|
||||
.mat-mdc-raised-button, .mat-mdc-outlined-button {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.border-b, .border-t { border-color: #e5e7eb; }
|
||||
h2 { font-weight: 600; line-height: 1.2; }
|
||||
.overflow-hidden { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); }
|
||||
|
||||
.flex.gap-1 button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.transition-colors {
|
||||
transition-property: background-color, color;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
mat-progress-bar { height: 2px; }
|
||||
.mat-mdc-tooltip { font-size: 12px; padding: 6px 12px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.text-4xl { font-size: 1.875rem; }
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.snackbar-success { background-color: #10b981; }
|
||||
.snackbar-error { background-color: #ef4444; }
|
||||
@ -1,12 +1,401 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSortModule, MatSort } from '@angular/material/sort';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs';
|
||||
import { SubCategoryService, SubCategory } from './subcategory.service';
|
||||
import { CategoryService, Category } from '../categories/category.service';
|
||||
import { BrandService, Brand } from '../brand/brand.service';
|
||||
import { BranchService, Branch } from '../../company/branch-list/branch.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sub-categories',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [
|
||||
CommonModule, ReactiveFormsModule, MatPaginatorModule, MatSortModule,
|
||||
MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule,
|
||||
MatIconModule, MatTooltipModule, MatProgressBarModule, MatSnackBarModule
|
||||
],
|
||||
templateUrl: './sub-categories.component.html',
|
||||
styleUrl: './sub-categories.component.scss'
|
||||
})
|
||||
export class SubCategoriesComponent {
|
||||
export class SubCategoriesComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
|
||||
}
|
||||
subCategories$: Observable<SubCategory[]>;
|
||||
private subCategoriesSubject = new BehaviorSubject<SubCategory[]>([]);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
searchInputControl = new FormControl('');
|
||||
selectedSubCategoryForm!: FormGroup;
|
||||
selectedSubCategory: SubCategory | null = null;
|
||||
isLoading = false;
|
||||
flashMessage: 'success' | 'error' | null = null;
|
||||
|
||||
pagination = { length: 0, page: 0, size: 10 };
|
||||
|
||||
// All data
|
||||
branches: Branch[] = [];
|
||||
brands: Brand[] = [];
|
||||
categories: Category[] = [];
|
||||
|
||||
// Filtered data for cascading
|
||||
filteredBrands: Brand[] = [];
|
||||
filteredCategories: Category[] = [];
|
||||
|
||||
// Auto-fill values
|
||||
selectedBranchId = '';
|
||||
selectedBranchName = '';
|
||||
selectedBrandId = '';
|
||||
selectedCategoryId = '';
|
||||
selectedCategoryName = '';
|
||||
previewSubCategoryId = 'XXX0001';
|
||||
|
||||
// Image handling
|
||||
imagePreview: string | null = null;
|
||||
selectedImageFile: File | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private subCategoryService: SubCategoryService,
|
||||
private categoryService: CategoryService,
|
||||
private brandService: BrandService,
|
||||
private branchService: BranchService,
|
||||
private snackBar: MatSnackBar
|
||||
) {
|
||||
this.subCategories$ = this.subCategoriesSubject.asObservable();
|
||||
this.initializeForm();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBranches();
|
||||
this.loadBrands();
|
||||
this.loadCategories();
|
||||
this.loadSubCategories();
|
||||
this.setupSearch();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeForm(): void {
|
||||
this.selectedSubCategoryForm = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
branch_id: ['', [Validators.required]],
|
||||
brand_id: ['', [Validators.required]],
|
||||
category_id: ['', [Validators.required]]
|
||||
});
|
||||
}
|
||||
|
||||
private setupSearch(): void {
|
||||
this.searchInputControl.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe((searchTerm) => {
|
||||
this.filterSubCategories(searchTerm || '');
|
||||
});
|
||||
}
|
||||
|
||||
loadBranches(): void {
|
||||
this.branchService.getAllBranches().pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (branches) => { this.branches = branches; },
|
||||
error: (error) => this.showSnackBar('Failed to load branches', 'error')
|
||||
});
|
||||
}
|
||||
|
||||
loadBrands(): void {
|
||||
this.brandService.getAllBrands().pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (brands) => { this.brands = brands; },
|
||||
error: (error) => this.showSnackBar('Failed to load brands', 'error')
|
||||
});
|
||||
}
|
||||
|
||||
loadCategories(): void {
|
||||
this.categoryService.getAllCategories().pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (categories) => { this.categories = categories; },
|
||||
error: (error) => this.showSnackBar('Failed to load categories', 'error')
|
||||
});
|
||||
}
|
||||
|
||||
loadSubCategories(): void {
|
||||
this.isLoading = true;
|
||||
this.subCategoryService.getAllSubCategories().pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (subCategories) => {
|
||||
this.subCategoriesSubject.next(subCategories);
|
||||
this.pagination.length = subCategories.length;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (error) => {
|
||||
this.showSnackBar('Failed to load sub-categories', 'error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private filterSubCategories(searchTerm: string): void {
|
||||
this.subCategoryService.getAllSubCategories().pipe(
|
||||
map(subCategories => {
|
||||
if (!searchTerm) return subCategories;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return subCategories.filter(sub =>
|
||||
sub.name.toLowerCase().includes(term) ||
|
||||
sub.sub_category_id.toLowerCase().includes(term) ||
|
||||
sub.category_name.toLowerCase().includes(term) ||
|
||||
sub.brand_name.toLowerCase().includes(term)
|
||||
);
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(filtered => {
|
||||
this.subCategoriesSubject.next(filtered);
|
||||
this.pagination.length = filtered.length;
|
||||
});
|
||||
}
|
||||
|
||||
onNameChange(event: any): void {
|
||||
const name = event.target.value;
|
||||
this.previewSubCategoryId = name ? this.generateSubCategoryIdPreview(name) : 'XXX0001';
|
||||
}
|
||||
|
||||
private generateSubCategoryIdPreview(name: string): string {
|
||||
const lettersOnly = name.replace(/[^a-zA-Z]/g, '').toUpperCase();
|
||||
const prefix = lettersOnly.substring(0, 3).padEnd(3, 'X');
|
||||
return `${prefix}0001`;
|
||||
}
|
||||
|
||||
// Level 1: Branch Selection
|
||||
onBranchChange(event: any): void {
|
||||
const branchId = event.value;
|
||||
const branch = this.branches.find(b => b.branch_id === branchId);
|
||||
|
||||
if (branch) {
|
||||
this.selectedBranchId = branch.branch_id;
|
||||
this.selectedBranchName = branch.name;
|
||||
|
||||
// Filter brands by branch
|
||||
this.filteredBrands = this.brands.filter(b => b.branch_id === branchId);
|
||||
|
||||
// Reset downstream selections
|
||||
this.selectedSubCategoryForm.patchValue({ brand_id: '', category_id: '' });
|
||||
this.selectedBrandId = '';
|
||||
this.selectedCategoryId = '';
|
||||
this.selectedCategoryName = '';
|
||||
this.filteredCategories = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Level 2: Brand Selection
|
||||
onBrandChange(event: any): void {
|
||||
const brandId = event.value;
|
||||
const brand = this.brands.find(b => b.brand_id === brandId);
|
||||
|
||||
if (brand) {
|
||||
this.selectedBrandId = brand.brand_id;
|
||||
|
||||
// Filter categories by brand
|
||||
this.filteredCategories = this.categories.filter(c => c.brand_id === brandId);
|
||||
|
||||
// Reset downstream selections
|
||||
this.selectedSubCategoryForm.patchValue({ category_id: '' });
|
||||
this.selectedCategoryId = '';
|
||||
this.selectedCategoryName = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Level 3: Category Selection
|
||||
onCategoryChange(event: any): void {
|
||||
const categoryId = event.value;
|
||||
const category = this.categories.find(c => c.category_id === categoryId);
|
||||
|
||||
if (category) {
|
||||
this.selectedCategoryId = category.category_id;
|
||||
this.selectedCategoryName = category.name;
|
||||
}
|
||||
}
|
||||
|
||||
onImageSelected(event: any): void {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
this.showSnackBar('Image size must be less than 5MB', 'error');
|
||||
return;
|
||||
}
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.showSnackBar('Please select an image file', 'error');
|
||||
return;
|
||||
}
|
||||
this.selectedImageFile = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: any) => { this.imagePreview = e.target.result; };
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
removeImage(): void {
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
if (this.selectedSubCategory?.id) this.selectedSubCategory.image = null;
|
||||
}
|
||||
|
||||
getFullImageUrl(imagePath: string | null): string {
|
||||
if (!imagePath) return '';
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
return `http://localhost:5000${imagePath}`;
|
||||
}
|
||||
|
||||
createSubCategory(): void {
|
||||
this.selectedSubCategory = {
|
||||
id: 0, sub_category_id: '', name: '', image: null,
|
||||
category_id: '', category_name: '', brand_id: '', brand_name: '',
|
||||
branch_id: '', branch_name: '', created_at: '', updated_at: ''
|
||||
};
|
||||
this.selectedSubCategoryForm.reset();
|
||||
this.selectedBranchId = '';
|
||||
this.selectedBranchName = '';
|
||||
this.selectedBrandId = '';
|
||||
this.selectedCategoryId = '';
|
||||
this.selectedCategoryName = '';
|
||||
this.filteredBrands = [];
|
||||
this.filteredCategories = [];
|
||||
this.previewSubCategoryId = 'XXX0001';
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
editSubCategory(subCategory: SubCategory): void {
|
||||
this.selectedSubCategory = { ...subCategory };
|
||||
this.selectedSubCategoryForm.patchValue({
|
||||
name: subCategory.name,
|
||||
branch_id: subCategory.branch_id,
|
||||
brand_id: subCategory.brand_id,
|
||||
category_id: subCategory.category_id
|
||||
});
|
||||
this.selectedBranchId = subCategory.branch_id;
|
||||
this.selectedBranchName = subCategory.branch_name;
|
||||
this.selectedBrandId = subCategory.brand_id;
|
||||
this.selectedCategoryId = subCategory.category_id;
|
||||
this.selectedCategoryName = subCategory.category_name;
|
||||
this.filteredBrands = this.brands.filter(b => b.branch_id === subCategory.branch_id);
|
||||
this.filteredCategories = this.categories.filter(c => c.brand_id === subCategory.brand_id);
|
||||
this.previewSubCategoryId = subCategory.sub_category_id;
|
||||
this.imagePreview = subCategory.image ? this.getFullImageUrl(subCategory.image) : null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
toggleDetails(subCategoryId: number): void {
|
||||
if (this.selectedSubCategory?.id === subCategoryId) {
|
||||
this.closeDetails();
|
||||
} else {
|
||||
const subCategory = this.subCategoriesSubject.value.find(s => s.id === subCategoryId);
|
||||
if (subCategory) this.editSubCategory(subCategory);
|
||||
}
|
||||
}
|
||||
|
||||
closeDetails(): void {
|
||||
this.selectedSubCategory = null;
|
||||
this.selectedSubCategoryForm.reset();
|
||||
this.selectedBranchId = '';
|
||||
this.selectedBranchName = '';
|
||||
this.selectedBrandId = '';
|
||||
this.selectedCategoryId = '';
|
||||
this.selectedCategoryName = '';
|
||||
this.filteredBrands = [];
|
||||
this.filteredCategories = [];
|
||||
this.previewSubCategoryId = 'XXX0001';
|
||||
this.imagePreview = null;
|
||||
this.selectedImageFile = null;
|
||||
this.flashMessage = null;
|
||||
}
|
||||
|
||||
updateSelectedSubCategory(): void {
|
||||
if (this.selectedSubCategoryForm.invalid) {
|
||||
this.selectedSubCategoryForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('name', this.selectedSubCategoryForm.get('name')?.value);
|
||||
formData.append('branch_id', this.selectedSubCategoryForm.get('branch_id')?.value);
|
||||
formData.append('brand_id', this.selectedSubCategoryForm.get('brand_id')?.value);
|
||||
formData.append('category_id', this.selectedSubCategoryForm.get('category_id')?.value);
|
||||
if (this.selectedImageFile) formData.append('image', this.selectedImageFile);
|
||||
|
||||
const operation = this.selectedSubCategory?.id
|
||||
? this.subCategoryService.updateSubCategory(this.selectedSubCategory.id, formData)
|
||||
: this.subCategoryService.createSubCategory(formData);
|
||||
|
||||
operation.pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (subCategory) => {
|
||||
this.flashMessage = 'success';
|
||||
this.isLoading = false;
|
||||
this.showSnackBar(
|
||||
this.selectedSubCategory?.id ? 'SubCategory updated successfully' : 'SubCategory created successfully',
|
||||
'success'
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.closeDetails();
|
||||
this.loadSubCategories();
|
||||
}, 1500);
|
||||
},
|
||||
error: (error) => {
|
||||
this.flashMessage = 'error';
|
||||
this.isLoading = false;
|
||||
this.showSnackBar(error.error?.error || 'Failed to save sub-category', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteSelectedSubCategory(): void {
|
||||
if (!this.selectedSubCategory?.id) return;
|
||||
if (!confirm(`Are you sure you want to delete sub-category "${this.selectedSubCategory.name}"?`)) return;
|
||||
this.deleteSubCategory(this.selectedSubCategory);
|
||||
}
|
||||
|
||||
deleteSubCategory(subCategory: SubCategory): void {
|
||||
if (!confirm(`Are you sure you want to delete sub-category "${subCategory.name}"?`)) return;
|
||||
this.isLoading = true;
|
||||
this.subCategoryService.deleteSubCategory(subCategory.id).pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.showSnackBar('SubCategory deleted successfully', 'success');
|
||||
this.closeDetails();
|
||||
this.loadSubCategories();
|
||||
},
|
||||
error: (error) => {
|
||||
this.showSnackBar(error.error?.error || 'Failed to delete sub-category', 'error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
trackByFn(index: number, item: SubCategory): number {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
private showSnackBar(message: string, type: 'success' | 'error'): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 3000,
|
||||
horizontalPosition: 'end',
|
||||
verticalPosition: 'top',
|
||||
panelClass: type === 'success' ? 'snackbar-success' : 'snackbar-error'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { environment } from '../../../../../../environments/environment';
|
||||
|
||||
export interface SubCategory {
|
||||
id: number;
|
||||
sub_category_id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
brand_id: string;
|
||||
brand_name: string;
|
||||
branch_id: string;
|
||||
branch_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// ⭐ NEW: Created by fields (automatic from backend)
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SubCategoryService {
|
||||
private apiUrl = `${environment.apiUrl}/subcategories`;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private getHeaders(): HttpHeaders {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
return new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
});
|
||||
}
|
||||
|
||||
getAllSubCategories(): Observable<SubCategory[]> {
|
||||
return this.http.get<SubCategory[]>(this.apiUrl, { headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getSubCategory(id: number): Observable<SubCategory> {
|
||||
return this.http.get<SubCategory>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new subcategory (with FormData for image upload)
|
||||
* ⭐ NOTE: Don't add created_by to FormData
|
||||
* Backend automatically extracts it from JWT token
|
||||
*/
|
||||
createSubCategory(formData: FormData): Observable<SubCategory> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
});
|
||||
return this.http.post<SubCategory>(this.apiUrl, formData, { headers })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing subcategory (with FormData for image upload)
|
||||
* ⭐ NOTE: Don't update created_by
|
||||
* It's set once during creation and shouldn't change
|
||||
*/
|
||||
updateSubCategory(id: number, formData: FormData): Observable<SubCategory> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
});
|
||||
return this.http.put<SubCategory>(`${this.apiUrl}/${id}`, formData, { headers })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteSubCategory(id: number): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
private handleError(error: any): Observable<never> {
|
||||
console.error('API Error:', error);
|
||||
let errorMessage = 'An error occurred';
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
errorMessage = error.error.message;
|
||||
} else {
|
||||
errorMessage = error.error?.error || error.message || 'Server error';
|
||||
}
|
||||
return throwError(() => ({ error: errorMessage, status: error.status }));
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { AuthService } from 'app/core/auth/auth.service';
|
||||
import { RoleStateService } from 'app/core/services/role-state.service';
|
||||
import { environment } from '@environments/environment';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
@ -28,7 +30,7 @@ interface Permission {
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, FormsModule]
|
||||
})
|
||||
export class RoleManagementComponent implements OnInit {
|
||||
export class RoleManagementComponent implements OnInit, OnDestroy {
|
||||
roles: Role[] = [];
|
||||
filteredRoles: Role[] = [];
|
||||
roleForm: FormGroup;
|
||||
@ -45,11 +47,13 @@ export class RoleManagementComponent implements OnInit {
|
||||
permissionMap: { [key: string]: Permission } = {};
|
||||
|
||||
private apiUrl = environment.apiUrl;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private http: HttpClient,
|
||||
private authService: AuthService
|
||||
private authService: AuthService,
|
||||
private roleStateService: RoleStateService
|
||||
) {
|
||||
this.roleForm = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.minLength(3)]],
|
||||
@ -63,6 +67,11 @@ export class RoleManagementComponent implements OnInit {
|
||||
this.loadRoles();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private getHeaders(): HttpHeaders {
|
||||
const token = this.authService.accessToken;
|
||||
return new HttpHeaders({
|
||||
@ -80,6 +89,7 @@ export class RoleManagementComponent implements OnInit {
|
||||
permissions.forEach(p => {
|
||||
this.permissionMap[p.id] = p;
|
||||
});
|
||||
console.log('✓ Permissions loaded:', permissions.length);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading permissions:', error);
|
||||
@ -100,6 +110,7 @@ export class RoleManagementComponent implements OnInit {
|
||||
this.roles = roles;
|
||||
this.filteredRoles = [...roles];
|
||||
this.isLoading = false;
|
||||
console.log('✓ Roles loaded:', roles.length);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading roles:', error);
|
||||
@ -124,6 +135,7 @@ export class RoleManagementComponent implements OnInit {
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.roleForm.invalid) {
|
||||
this.showError('Please fill in all required fields correctly');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -138,15 +150,30 @@ export class RoleManagementComponent implements OnInit {
|
||||
{ headers: this.getHeaders() }
|
||||
).subscribe({
|
||||
next: (updatedRole) => {
|
||||
// Update role in local arrays
|
||||
const index = this.roles.findIndex(r => r.id === this.currentRoleId);
|
||||
if (index !== -1) {
|
||||
this.roles[index] = updatedRole;
|
||||
}
|
||||
this.filteredRoles = [...this.roles];
|
||||
|
||||
const filteredIndex = this.filteredRoles.findIndex(r => r.id === this.currentRoleId);
|
||||
if (filteredIndex !== -1) {
|
||||
this.filteredRoles[filteredIndex] = updatedRole;
|
||||
}
|
||||
|
||||
// ⭐ Notify other components about role update
|
||||
this.roleStateService.notifyRoleUpdate(
|
||||
updatedRole.id,
|
||||
updatedRole.name,
|
||||
updatedRole.permissions
|
||||
);
|
||||
|
||||
this.showSuccess('Role updated successfully');
|
||||
this.resetForm();
|
||||
this.showAddRoleForm = false;
|
||||
this.isLoading = false;
|
||||
|
||||
console.log('✓ Role updated:', updatedRole);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error updating role:', error);
|
||||
@ -162,12 +189,23 @@ export class RoleManagementComponent implements OnInit {
|
||||
{ headers: this.getHeaders() }
|
||||
).subscribe({
|
||||
next: (newRole) => {
|
||||
// Add new role to arrays
|
||||
this.roles.push(newRole);
|
||||
this.filteredRoles = [...this.roles];
|
||||
|
||||
// ⭐ Notify other components about new role
|
||||
this.roleStateService.notifyRoleCreated(
|
||||
newRole.id,
|
||||
newRole.name,
|
||||
newRole.permissions
|
||||
);
|
||||
|
||||
this.showSuccess('Role created successfully');
|
||||
this.resetForm();
|
||||
this.showAddRoleForm = false;
|
||||
this.isLoading = false;
|
||||
|
||||
console.log('✓ Role created:', newRole);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error creating role:', error);
|
||||
@ -184,13 +222,18 @@ export class RoleManagementComponent implements OnInit {
|
||||
this.roleForm.patchValue({
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
permissions: role.permissions
|
||||
permissions: [...role.permissions]
|
||||
});
|
||||
this.showAddRoleForm = true;
|
||||
|
||||
console.log('Editing role:', role.name, 'Permissions:', role.permissions);
|
||||
}
|
||||
|
||||
deleteRole(id: number): void {
|
||||
if (!confirm('Are you sure you want to delete this role? This action cannot be undone.')) {
|
||||
const role = this.roles.find(r => r.id === id);
|
||||
if (!role) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the "${role.name}" role? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -198,10 +241,17 @@ export class RoleManagementComponent implements OnInit {
|
||||
this.http.delete(`${this.apiUrl}/roles/${id}`, { headers: this.getHeaders() })
|
||||
.subscribe({
|
||||
next: () => {
|
||||
// Remove from both arrays
|
||||
this.roles = this.roles.filter(r => r.id !== id);
|
||||
this.filteredRoles = this.filteredRoles.filter(r => r.id !== id);
|
||||
|
||||
// ⭐ Notify other components about role deletion
|
||||
this.roleStateService.notifyRoleDeleted(id);
|
||||
|
||||
this.showSuccess('Role deleted successfully');
|
||||
this.isLoading = false;
|
||||
|
||||
console.log('✓ Role deleted, ID:', id);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error deleting role:', error);
|
||||
@ -254,6 +304,8 @@ export class RoleManagementComponent implements OnInit {
|
||||
const newPermissions = currentPermissions.filter(id => !modulePermissionIds.includes(id));
|
||||
this.roleForm.get('permissions')?.setValue(newPermissions);
|
||||
}
|
||||
|
||||
this.roleForm.get('permissions')?.markAsDirty();
|
||||
}
|
||||
|
||||
isModuleFullySelected(module: string): boolean {
|
||||
@ -262,7 +314,8 @@ export class RoleManagementComponent implements OnInit {
|
||||
.map(p => p.id);
|
||||
|
||||
const currentPermissions = this.roleForm.get('permissions')?.value || [];
|
||||
return modulePermissionIds.every(id => currentPermissions.includes(id));
|
||||
return modulePermissionIds.length > 0 &&
|
||||
modulePermissionIds.every(id => currentPermissions.includes(id));
|
||||
}
|
||||
|
||||
getPermissionsByModule(module: string): Permission[] {
|
||||
@ -286,10 +339,18 @@ export class RoleManagementComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.roleForm.get('permissions')?.setValue(permissions);
|
||||
this.roleForm.get('permissions')?.markAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ Toggle permission in the table with immediate local update
|
||||
*/
|
||||
togglePermission(role: Role, permissionId: string, checked: boolean): void {
|
||||
console.log(`Toggling permission: ${permissionId} for role: ${role.name}, checked: ${checked}`);
|
||||
|
||||
// Create updated permissions array
|
||||
const updatedPermissions = [...role.permissions];
|
||||
|
||||
if (checked) {
|
||||
if (!updatedPermissions.includes(permissionId)) {
|
||||
updatedPermissions.push(permissionId);
|
||||
@ -301,33 +362,69 @@ export class RoleManagementComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
// Update role on backend
|
||||
// ⭐ IMMEDIATELY update local state for instant UI feedback
|
||||
const roleIndex = this.roles.findIndex(r => r.id === role.id);
|
||||
if (roleIndex !== -1) {
|
||||
this.roles[roleIndex] = {
|
||||
...this.roles[roleIndex],
|
||||
permissions: updatedPermissions
|
||||
};
|
||||
}
|
||||
|
||||
const filteredIndex = this.filteredRoles.findIndex(r => r.id === role.id);
|
||||
if (filteredIndex !== -1) {
|
||||
this.filteredRoles[filteredIndex] = {
|
||||
...this.filteredRoles[filteredIndex],
|
||||
permissions: updatedPermissions
|
||||
};
|
||||
}
|
||||
|
||||
// Update backend
|
||||
this.http.put<Role>(
|
||||
`${this.apiUrl}/roles/${role.id}`,
|
||||
{ permissions: updatedPermissions },
|
||||
{ headers: this.getHeaders() }
|
||||
).subscribe({
|
||||
next: (updatedRole) => {
|
||||
const roleIndex = this.roles.findIndex(r => r.id === role.id);
|
||||
if (roleIndex !== -1) {
|
||||
this.roles[roleIndex] = updatedRole;
|
||||
this.filteredRoles = [...this.roles];
|
||||
console.log('✓ Permission updated on backend:', updatedRole);
|
||||
|
||||
// Update with backend response
|
||||
const roleIdx = this.roles.findIndex(r => r.id === role.id);
|
||||
if (roleIdx !== -1) {
|
||||
this.roles[roleIdx] = updatedRole;
|
||||
}
|
||||
|
||||
const filteredIdx = this.filteredRoles.findIndex(r => r.id === role.id);
|
||||
if (filteredIdx !== -1) {
|
||||
this.filteredRoles[filteredIdx] = updatedRole;
|
||||
}
|
||||
|
||||
// ⭐ Notify other components about permission change
|
||||
this.roleStateService.notifyRoleUpdate(
|
||||
updatedRole.id,
|
||||
updatedRole.name,
|
||||
updatedRole.permissions
|
||||
);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error updating permissions:', error);
|
||||
this.showError('Failed to update permissions');
|
||||
|
||||
// Rollback on error
|
||||
this.loadRoles();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(message: string): void {
|
||||
this.successMessage = message;
|
||||
this.errorMessage = null;
|
||||
setTimeout(() => this.successMessage = null, 3000);
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.errorMessage = message;
|
||||
this.successMessage = null;
|
||||
setTimeout(() => this.errorMessage = null, 5000);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// role.service.ts - NEW SERVICE FOR ROLE MANAGEMENT
|
||||
// role-management.service.ts - COMPLETE SERVICE
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, BehaviorSubject } from 'rxjs';
|
||||
@ -16,9 +16,11 @@ export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: string[]; // Array of permission IDs
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
@ -33,8 +35,7 @@ export class RoleService {
|
||||
public roles$ = this._roles.asObservable();
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
// Load roles on service initialization
|
||||
this.loadRoles();
|
||||
console.log('✓ RoleService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -44,20 +45,11 @@ export class RoleService {
|
||||
return this.http.get<Role[]>(this.apiUrl).pipe(
|
||||
tap(roles => {
|
||||
this._roles.next(roles);
|
||||
console.log('✓ Roles cached:', roles.length);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load roles and cache them
|
||||
*/
|
||||
loadRoles(): void {
|
||||
this.getRoles().subscribe(
|
||||
roles => console.log('Roles loaded:', roles.length),
|
||||
error => console.error('Error loading roles:', error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single role by ID
|
||||
*/
|
||||
@ -69,27 +61,21 @@ export class RoleService {
|
||||
* Create new role
|
||||
*/
|
||||
createRole(role: Partial<Role>): Observable<Role> {
|
||||
return this.http.post<Role>(this.apiUrl, role).pipe(
|
||||
tap(() => this.loadRoles()) // Reload after creation
|
||||
);
|
||||
return this.http.post<Role>(this.apiUrl, role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing role
|
||||
*/
|
||||
updateRole(id: number, role: Partial<Role>): Observable<Role> {
|
||||
return this.http.put<Role>(`${this.apiUrl}/${id}`, role).pipe(
|
||||
tap(() => this.loadRoles()) // Reload after update
|
||||
);
|
||||
return this.http.put<Role>(`${this.apiUrl}/${id}`, role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete role
|
||||
*/
|
||||
deleteRole(id: number): Observable<any> {
|
||||
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
|
||||
tap(() => this.loadRoles()) // Reload after deletion
|
||||
);
|
||||
return this.http.delete(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,32 +86,7 @@ export class RoleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter roles based on current user's role
|
||||
* Maintains the same permission logic
|
||||
*/
|
||||
getAvailableRolesForUser(currentUserRole: string): Observable<Role[]> {
|
||||
return this.roles$.pipe(
|
||||
tap(roles => {
|
||||
let filteredRoles: Role[];
|
||||
|
||||
if (currentUserRole === 'Client') {
|
||||
// Clients can only assign Refiller role
|
||||
filteredRoles = roles.filter(role => role.name === 'Refiller');
|
||||
} else if (['Management', 'SuperAdmin', 'Admin'].includes(currentUserRole)) {
|
||||
// Admins can assign any role
|
||||
filteredRoles = roles;
|
||||
} else {
|
||||
// Default - no roles available
|
||||
filteredRoles = [];
|
||||
}
|
||||
|
||||
console.log(`Available roles for ${currentUserRole}:`, filteredRoles);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Role to dropdown format
|
||||
* Convert Role array to dropdown format
|
||||
*/
|
||||
rolesToDropdownFormat(roles: Role[]): { value: string; label: string }[] {
|
||||
return roles.map(role => ({
|
||||
@ -135,9 +96,23 @@ export class RoleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role by name
|
||||
* Get role by name from cached roles
|
||||
*/
|
||||
getRoleByName(name: string): Role | undefined {
|
||||
return this._roles.getValue().find(role => role.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cached roles
|
||||
*/
|
||||
getCachedRoles(): Role[] {
|
||||
return this._roles.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached roles
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._roles.next([]);
|
||||
}
|
||||
}
|
||||
@ -33,7 +33,7 @@
|
||||
@if (users$ | async; as users) {
|
||||
@if (users.length > 0 || selectedUser) {
|
||||
<div class="grid">
|
||||
<!-- Header -->
|
||||
<!-- Header - NOW 10 COLUMNS -->
|
||||
<div class="user-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-md font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
|
||||
matSort matSortDisableClear>
|
||||
<div class="hidden md:block" [mat-sort-header]="'id'">ID</div>
|
||||
@ -42,6 +42,12 @@
|
||||
<div class="hidden lg:block" [mat-sort-header]="'contact'">Contact</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'roles'">Role</div>
|
||||
<div class="hidden lg:block" [mat-sort-header]="'user_status'">Status</div>
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block">Created By</div>
|
||||
<!-- ⭐ NEW: Assigned To Column -->
|
||||
<div class="hidden xl:block">Assigned To</div>
|
||||
<!-- ⭐ NEW: Machines Column -->
|
||||
<div class="hidden xl:block">Machines</div>
|
||||
<div class="hidden sm:block">Details</div>
|
||||
</div>
|
||||
|
||||
@ -91,6 +97,22 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Created By (Auto) -->
|
||||
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>Auto</span>
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Assigned To -->
|
||||
<div class="hidden xl:block text-blue-600">
|
||||
<span class="text-xs">-</span>
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Machines -->
|
||||
<div class="hidden xl:block text-blue-600">
|
||||
<span class="text-xs">-</span>
|
||||
</div>
|
||||
|
||||
<!-- Details button -->
|
||||
<div class="hidden sm:block">
|
||||
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button [color]="'primary'"
|
||||
@ -178,6 +200,40 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Created By Column -->
|
||||
<div class="hidden xl:block flex items-center gap-1">
|
||||
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
<span>{{ user.created_by_username || 'N/A' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Assigned To (Client) Column -->
|
||||
<div class="hidden xl:block flex items-center gap-1">
|
||||
@if (user.roles === 'Refiller') {
|
||||
@if (user.assigned_to_username) {
|
||||
<mat-icon class="icon-size-4 text-blue-600" [svgIcon]="'heroicons_outline:user-group'"></mat-icon>
|
||||
<span class="text-blue-600">{{ user.assigned_to_username }}</span>
|
||||
} @else {
|
||||
<span class="text-gray-400 text-xs">Not Assigned</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="text-gray-400 text-xs">-</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Machines Count Column -->
|
||||
<div class="hidden xl:block">
|
||||
@if (user.roles === 'Refiller' && user.assigned_machines_count && user.assigned_machines_count > 0) {
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<mat-icon class="icon-size-3 mr-1" [svgIcon]="'heroicons_outline:cog'"></mat-icon>
|
||||
{{ user.assigned_machines_count }} machines
|
||||
</span>
|
||||
} @else if (user.roles === 'Refiller') {
|
||||
<span class="text-gray-400 text-xs">No machines</span>
|
||||
} @else {
|
||||
<span class="text-gray-400 text-xs">-</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Details button -->
|
||||
<div class="hidden sm:block">
|
||||
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button
|
||||
@ -344,6 +400,33 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- ⭐ NEW: Created By Field (Read-only) -->
|
||||
@if (user.id && user.created_by_username) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>Created By</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="user.created_by_username"
|
||||
readonly
|
||||
/>
|
||||
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- ⭐ NEW: Client Assignment (only for Refillers) -->
|
||||
@if (showClientAssignment) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>Assign to Client</mat-label>
|
||||
<mat-select [formControlName]="'assigned_to'">
|
||||
<mat-option [value]="''">Not Assigned</mat-option>
|
||||
<mat-option *ngFor="let client of availableClients" [value]="client.id">
|
||||
{{ client.username }} ({{ client.email }})
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Select which Client this Refiller works for</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (user.id) {
|
||||
<div class="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<h4 class="text-sm font-medium text-gray-900">Account Information</h4>
|
||||
@ -359,6 +442,60 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ⭐ NEW: Machine Assignment Section (only when Refiller is assigned to a Client) -->
|
||||
@if (showMachineAssignment) {
|
||||
<div class="flex w-full flex-col mt-6 pt-6 border-t">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<mat-icon class="text-blue-600" [svgIcon]="'heroicons_outline:cog'"></mat-icon>
|
||||
Assign Machines
|
||||
</h3>
|
||||
|
||||
@if (machinesLoading) {
|
||||
<div class="p-4 text-center">
|
||||
<mat-icon class="animate-spin text-blue-600">refresh</mat-icon>
|
||||
<p class="text-sm text-gray-600 mt-2">Loading machines...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!machinesLoading && availableMachines.length === 0) {
|
||||
<p class="text-sm text-gray-500 p-4 border rounded bg-gray-50">
|
||||
No machines available for selected client
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (!machinesLoading && availableMachines.length > 0) {
|
||||
<mat-form-field class="w-full" appearance="outline">
|
||||
<mat-label>Select Machines</mat-label>
|
||||
<mat-select [(value)]="selectedMachineIds" multiple>
|
||||
<mat-option *ngFor="let machine of availableMachines" [value]="machine.machine_id">
|
||||
<div class="flex flex-col py-1">
|
||||
<span class="font-medium">{{ machine.machine_id }} - {{ machine.machine_model }}</span>
|
||||
<span class="text-xs text-gray-500">{{ machine.branch_name }} • {{ machine.operation_status }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Select machines this Refiller will maintain</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Show currently selected machines -->
|
||||
@if (selectedMachineIds.length > 0) {
|
||||
<div class="mt-2">
|
||||
<span class="text-xs text-gray-600">{{ selectedMachineIds.length }} machine(s) selected</span>
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<span *ngFor="let machineId of selectedMachineIds" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ machineId }}
|
||||
<button type="button" (click)="removeMachineId(machineId)"
|
||||
class="ml-1 hover:bg-blue-200 rounded-full p-0.5">
|
||||
<mat-icon class="icon-size-3">close</mat-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Documents Section -->
|
||||
<div class="flex w-full flex-col mt-6 pt-6 border-t">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900">Documents & Files</h3>
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
/* User grid layout */
|
||||
/* User grid layout - NOW 10 COLUMNS: ID, Username, Email, Contact, Role, Status, Created By, Assigned To, Machines, Details */
|
||||
.user-grid {
|
||||
// Default: ID, Username, Email, Contact, Role, Status, Details (7 columns)
|
||||
grid-template-columns: 60px 2fr 2fr 1.5fr 1fr 1fr 80px;
|
||||
// Default: All 10 columns visible on XL screens
|
||||
grid-template-columns: 60px 2fr 2fr 1.5fr 1fr 1fr 150px 150px 120px 80px;
|
||||
|
||||
@media (max-width: 1023px) { // lg breakpoint - hide contact and role
|
||||
@media (max-width: 1279px) { // xl breakpoint - hide Assigned To and Machines
|
||||
grid-template-columns: 60px 2fr 2fr 1.5fr 1fr 1fr 150px 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) { // lg breakpoint - hide Created By, contact and role
|
||||
grid-template-columns: 60px 2fr 2fr 1fr 80px;
|
||||
}
|
||||
|
||||
@ -16,6 +20,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Created By Column Styling */
|
||||
.user-grid > div:nth-child(7) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-grid > div:nth-child(7) mat-icon {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.user-grid > div:nth-child(7) span {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* ⭐ NEW: Assigned To Column Styling */
|
||||
.user-grid > div:nth-child(8) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-grid > div:nth-child(8) mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.user-grid > div:nth-child(8) span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ⭐ NEW: Machines Column Styling */
|
||||
.user-grid > div:nth-child(9) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-grid > div:nth-child(9) mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.user-grid > div:nth-child(9) span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dark .user-grid > div:nth-child(7) mat-icon,
|
||||
.dark .user-grid > div:nth-child(8) mat-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .user-grid > div:nth-child(7) span,
|
||||
.dark .user-grid > div:nth-child(8) span {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Material form field customizations */
|
||||
.fuse-mat-dense {
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
@ -52,6 +119,12 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.icon-size-3 {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Custom button styles */
|
||||
.mat-mdc-button {
|
||||
&.mat-primary {
|
||||
@ -271,6 +344,7 @@
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional styles for user.component.scss */
|
||||
|
||||
/* File upload section styles */
|
||||
@ -585,6 +659,7 @@
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.doc-type-badge {
|
||||
&.aadhar { background: #fef2f2; color: #991b1b; }
|
||||
&.pan { background: #eff6ff; color: #1e40af; }
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// user.component.ts - UPDATED WITH DYNAMIC ROLE FETCHING
|
||||
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core';
|
||||
// user.component.ts - WITH REAL-TIME ROLE UPDATES
|
||||
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, AfterViewInit } from '@angular/core';
|
||||
import { FormBuilder, FormControl, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
|
||||
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
@ -17,7 +17,22 @@ import { Observable, Subject, BehaviorSubject, combineLatest } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, map, startWith, takeUntil } from 'rxjs/operators';
|
||||
import { User, UserService, UserDocument, DocumentWithMetadata } from '../user/user.service';
|
||||
import { AuthService } from 'app/core/auth/auth.service';
|
||||
import { RoleService, Role } from '../role-management/role-management.service'; // IMPORT ROLE SERVICE
|
||||
import { RoleService, Role } from '../role-management/role-management.service';
|
||||
import { RoleStateService } from 'app/core/services/role-state.service';
|
||||
|
||||
// ⭐ NEW: Import types for assignment
|
||||
interface Client {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Machine {
|
||||
machine_id: string;
|
||||
machine_model: string;
|
||||
branch_name: string;
|
||||
operation_status: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-user',
|
||||
@ -41,7 +56,7 @@ import { RoleService, Role } from '../role-management/role-management.service';
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class UserComponent implements OnInit, OnDestroy {
|
||||
export class UserComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
|
||||
@ -58,13 +73,25 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
selectedUser: User | null = null;
|
||||
flashMessage: 'success' | 'error' | null = null;
|
||||
|
||||
// Role-based properties - UPDATED TO USE DYNAMIC ROLES
|
||||
// Role-based properties
|
||||
currentUserRole: string | null = null;
|
||||
isClient: boolean = false;
|
||||
isAdmin: boolean = false;
|
||||
availableRoles: { value: string; label: string }[] = [];
|
||||
allRoles: Role[] = []; // Store all roles from database
|
||||
allRoles: Role[] = [];
|
||||
rolesLoading: boolean = false;
|
||||
rolesLoaded: boolean = false;
|
||||
|
||||
// ⭐ NEW: Option 3 - Client Assignment
|
||||
availableClients: Client[] = [];
|
||||
clientsLoading: boolean = false;
|
||||
showClientAssignment: boolean = false;
|
||||
|
||||
// ⭐ NEW: Option 3 - Machine Assignment
|
||||
availableMachines: Machine[] = [];
|
||||
selectedMachineIds: string[] = [];
|
||||
showMachineAssignment: boolean = false;
|
||||
machinesLoading: boolean = false;
|
||||
|
||||
// Forms
|
||||
searchInputControl: FormControl = new FormControl('');
|
||||
@ -106,16 +133,18 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
private _formBuilder: FormBuilder,
|
||||
private _changeDetectorRef: ChangeDetectorRef,
|
||||
private _authService: AuthService,
|
||||
private _roleService: RoleService // INJECT ROLE SERVICE
|
||||
private _roleService: RoleService,
|
||||
private _roleStateService: RoleStateService // ⭐ NEW: Inject RoleStateService
|
||||
) {
|
||||
// Initialize form
|
||||
// Initialize form with assigned_to
|
||||
this.selectedUserForm = this._formBuilder.group({
|
||||
username: ['', [Validators.required]],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
contact: ['', [Validators.required, Validators.pattern('^[0-9]{10}$')]],
|
||||
roles: ['', [Validators.required]],
|
||||
user_status: ['Active'],
|
||||
password: ['', [Validators.required, Validators.minLength(6)]]
|
||||
password: [''],
|
||||
assigned_to: ['']
|
||||
});
|
||||
}
|
||||
|
||||
@ -126,10 +155,40 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
this.isAdmin = this._authService.hasAnyRole(['Management', 'SuperAdmin', 'Admin']);
|
||||
|
||||
console.log('User Management - Current Role:', this.currentUserRole);
|
||||
console.log('Is Client:', this.isClient);
|
||||
console.log('Is Admin:', this.isAdmin);
|
||||
|
||||
// Load roles from database
|
||||
// Load roles from database FIRST
|
||||
this.loadRolesFromDatabase();
|
||||
|
||||
// ⭐ NEW: Subscribe to role changes from Role Management
|
||||
this.subscribeToRoleChanges();
|
||||
|
||||
// Load clients for dropdown (only for admins)
|
||||
if (this.isAdmin) {
|
||||
this.loadClients();
|
||||
}
|
||||
|
||||
// Watch for role and client changes to show/hide machine assignment
|
||||
combineLatest([
|
||||
this.selectedUserForm.get('roles')!.valueChanges.pipe(startWith('')),
|
||||
this.selectedUserForm.get('assigned_to')!.valueChanges.pipe(startWith(''))
|
||||
]).pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe(([role, clientId]) => {
|
||||
// Show client assignment dropdown only for Refillers and Admins
|
||||
this.showClientAssignment = role === 'Refiller' && this.isAdmin;
|
||||
|
||||
// Show machine assignment only when role is Refiller, user is Admin, and client is assigned
|
||||
this.showMachineAssignment = role === 'Refiller' && this.isAdmin && !!clientId && clientId !== '';
|
||||
|
||||
if (this.showMachineAssignment && clientId) {
|
||||
this.loadClientMachines(parseInt(clientId, 10));
|
||||
} else {
|
||||
this.availableMachines = [];
|
||||
this.selectedMachineIds = [];
|
||||
}
|
||||
});
|
||||
|
||||
this.users$ = this._users.asObservable();
|
||||
|
||||
this.searchInputControl.valueChanges
|
||||
@ -143,14 +202,152 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
this.filterUsers();
|
||||
});
|
||||
|
||||
this.loadUsers();
|
||||
// Load users after a small delay to ensure roles are loaded
|
||||
setTimeout(() => {
|
||||
this.loadUsers();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.paginator) {
|
||||
this.paginator.page
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe((page: PageEvent) => {
|
||||
this.pagination.page = page.pageIndex;
|
||||
this.pagination.size = page.pageSize;
|
||||
this.filterUsers();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.sort) {
|
||||
this.sort.sortChange
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe((sort: Sort) => {
|
||||
this.sortUsers(sort);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._unsubscribeAll.next(null);
|
||||
this._unsubscribeAll.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATED: Load roles from database instead of hardcoded values
|
||||
* ⭐ NEW: Subscribe to role changes from Role Management component
|
||||
*/
|
||||
private subscribeToRoleChanges(): void {
|
||||
console.log('📢 Subscribing to role changes...');
|
||||
|
||||
// Listen for role updates (permission changes)
|
||||
this._roleStateService.roleUpdated$
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe(update => {
|
||||
if (update) {
|
||||
console.log('📢 ROLE UPDATED notification received:', update);
|
||||
|
||||
// Reload roles to get latest permissions
|
||||
this.loadRolesFromDatabase();
|
||||
|
||||
// Update users who have this role
|
||||
this.updateUsersWithRole(update.roleName);
|
||||
|
||||
// Show notification
|
||||
this.showRoleUpdateNotification(`Role "${update.roleName}" permissions updated`);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for role deletions
|
||||
this._roleStateService.roleDeleted$
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe(roleId => {
|
||||
if (roleId) {
|
||||
console.log('📢 ROLE DELETED notification received:', roleId);
|
||||
|
||||
// Reload roles and users
|
||||
this.loadRolesFromDatabase();
|
||||
this.loadUsers();
|
||||
|
||||
// Show notification
|
||||
this.showRoleUpdateNotification('A role has been deleted. User list refreshed.');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for new roles
|
||||
this._roleStateService.roleCreated$
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe(update => {
|
||||
if (update) {
|
||||
console.log('📢 ROLE CREATED notification received:', update);
|
||||
|
||||
// Reload roles
|
||||
this.loadRolesFromDatabase();
|
||||
|
||||
// Show notification
|
||||
this.showRoleUpdateNotification(`New role "${update.roleName}" created`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ NEW: Update users in the list who have the changed role
|
||||
*/
|
||||
private updateUsersWithRole(roleName: string): void {
|
||||
const users = this._users.getValue();
|
||||
let updatedCount = 0;
|
||||
|
||||
// Mark users with this role as needing refresh
|
||||
const updatedUsers = users.map(user => {
|
||||
if (user.roles === roleName) {
|
||||
updatedCount++;
|
||||
// Flag for UI update - you might want to show an indicator
|
||||
return { ...user, _roleUpdated: true };
|
||||
}
|
||||
return user;
|
||||
});
|
||||
|
||||
this._users.next(updatedUsers);
|
||||
this.filterUsers();
|
||||
|
||||
console.log(`✓ Flagged ${updatedCount} users with role: ${roleName} for update`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ NEW: Show notification about role updates
|
||||
*/
|
||||
private showRoleUpdateNotification(message: string): void {
|
||||
console.log('🔔 Role Update:', message);
|
||||
|
||||
// Option 1: Browser notification (if permission granted)
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Role Management Update', {
|
||||
body: message,
|
||||
icon: '/assets/icons/icon-72x72.png' // Adjust path
|
||||
});
|
||||
}
|
||||
|
||||
// Option 2: Console log (always)
|
||||
console.log('📬', message);
|
||||
|
||||
// Option 3: Show flash message
|
||||
this.flashMessage = 'success';
|
||||
setTimeout(() => {
|
||||
this.flashMessage = null;
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}, 3000);
|
||||
|
||||
// Option 4: Add a visual indicator in the UI
|
||||
// You could add a badge or highlight to the role management button
|
||||
}
|
||||
|
||||
/**
|
||||
* Load roles from database
|
||||
*/
|
||||
private loadRolesFromDatabase(): void {
|
||||
this.rolesLoading = true;
|
||||
this.rolesLoaded = false;
|
||||
|
||||
console.log('Loading roles from database...');
|
||||
|
||||
this._roleService.getRoles()
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
@ -163,11 +360,15 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
this.setAvailableRoles(roles);
|
||||
|
||||
this.rolesLoading = false;
|
||||
this.rolesLoaded = true;
|
||||
this._changeDetectorRef.markForCheck();
|
||||
|
||||
console.log('Available roles for dropdown:', this.availableRoles);
|
||||
},
|
||||
(error) => {
|
||||
console.error('✗ Error loading roles:', error);
|
||||
this.rolesLoading = false;
|
||||
this.rolesLoaded = true;
|
||||
|
||||
// Fallback to empty roles if error
|
||||
this.availableRoles = [];
|
||||
@ -177,7 +378,7 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATED: Filter roles based on current user's role (from database)
|
||||
* Filter roles based on current user's role
|
||||
*/
|
||||
private setAvailableRoles(roles: Role[]): void {
|
||||
let filteredRoles: Role[] = [];
|
||||
@ -202,6 +403,52 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
console.log('Available roles for dropdown:', this.availableRoles);
|
||||
}
|
||||
|
||||
// ⭐ NEW: Load Clients for assignment dropdown
|
||||
private loadClients(): void {
|
||||
this.clientsLoading = true;
|
||||
this._userService.getClients()
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe({
|
||||
next: (clients) => {
|
||||
this.availableClients = clients;
|
||||
this.clientsLoading = false;
|
||||
console.log('Loaded clients:', clients);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading clients:', err);
|
||||
this.clientsLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ⭐ NEW: Load machines for selected client
|
||||
private loadClientMachines(clientId: number): void {
|
||||
if (!clientId) {
|
||||
this.availableMachines = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.machinesLoading = true;
|
||||
this._userService.getClientMachines(clientId)
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe({
|
||||
next: (machines) => {
|
||||
this.availableMachines = machines;
|
||||
this.machinesLoading = false;
|
||||
console.log('Loaded machines for client:', machines);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading client machines:', err);
|
||||
this.machinesLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ⭐ NEW: Remove machine ID from selection
|
||||
removeMachineId(machineId: string): void {
|
||||
this.selectedMachineIds = this.selectedMachineIds.filter(id => id !== machineId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh roles from database
|
||||
*/
|
||||
@ -210,29 +457,38 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
this.loadRolesFromDatabase();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._unsubscribeAll.next(null);
|
||||
this._unsubscribeAll.complete();
|
||||
/**
|
||||
* Check if form is ready to submit
|
||||
*/
|
||||
isFormReady(): boolean {
|
||||
return this.selectedUserForm.valid && !this.isLoading && !this.rolesLoading && this.rolesLoaded;
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.paginator) {
|
||||
this.paginator.page
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe((page: PageEvent) => {
|
||||
this.pagination.page = page.pageIndex;
|
||||
this.pagination.size = page.pageSize;
|
||||
this.filterUsers();
|
||||
/**
|
||||
* Debug form validation
|
||||
*/
|
||||
debugFormValidation(): void {
|
||||
console.log('=== FORM VALIDATION DEBUG ===');
|
||||
console.log('Form valid:', this.selectedUserForm.valid);
|
||||
console.log('Form errors:', this.selectedUserForm.errors);
|
||||
console.log('Roles loaded:', this.rolesLoaded);
|
||||
console.log('Roles loading:', this.rolesLoading);
|
||||
console.log('Available roles:', this.availableRoles);
|
||||
|
||||
Object.keys(this.selectedUserForm.controls).forEach(key => {
|
||||
const control = this.selectedUserForm.get(key);
|
||||
if (control?.invalid) {
|
||||
console.log(`❌ ${key}:`, {
|
||||
errors: control.errors,
|
||||
value: control.value,
|
||||
touched: control.touched,
|
||||
dirty: control.dirty
|
||||
});
|
||||
}
|
||||
|
||||
if (this.sort) {
|
||||
this.sort.sortChange
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe((sort: Sort) => {
|
||||
this.sortUsers(sort);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log(`✓ ${key}:`, control?.value);
|
||||
}
|
||||
});
|
||||
console.log('=== END DEBUG ===');
|
||||
}
|
||||
|
||||
trackByFn(index: number, item: any): any {
|
||||
@ -336,14 +592,30 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
email: user.email,
|
||||
contact: user.contact,
|
||||
roles: user.roles,
|
||||
user_status: user.user_status
|
||||
user_status: user.user_status,
|
||||
assigned_to: user.assigned_to || ''
|
||||
});
|
||||
|
||||
this.photoPreview = this._userService.getFullFileUrl(user.photo);
|
||||
this.logoPreview = this._userService.getFullFileUrl(user.company_logo);
|
||||
|
||||
// Load assigned machines for Refillers
|
||||
if (user.roles === 'Refiller') {
|
||||
this.selectedMachineIds = user.assigned_machines?.map((m: any) => m.machine_id) || [];
|
||||
|
||||
// Load available machines if client is assigned
|
||||
if (user.assigned_to) {
|
||||
this.loadClientMachines(user.assigned_to);
|
||||
}
|
||||
} else {
|
||||
this.selectedMachineIds = [];
|
||||
}
|
||||
|
||||
// For existing users, password is optional
|
||||
this.selectedUserForm.get('password')?.clearValidators();
|
||||
this.selectedUserForm.get('password')?.updateValueAndValidity();
|
||||
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@ -359,15 +631,32 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
this.photoPreview = null;
|
||||
this.logoPreview = null;
|
||||
this.viewingDocument = null;
|
||||
this.selectedMachineIds = [];
|
||||
this.selectedUserForm.reset();
|
||||
this._changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
createUser(): void {
|
||||
console.log('Creating new user...');
|
||||
console.log('Roles loaded:', this.rolesLoaded);
|
||||
console.log('Available roles:', this.availableRoles);
|
||||
|
||||
if (this.selectedUser) {
|
||||
this.closeDetails();
|
||||
}
|
||||
|
||||
// For clients, auto-set role to Refiller
|
||||
const defaultRole = this.isClient ? 'Refiller' : '';
|
||||
// Determine default role
|
||||
let defaultRole = '';
|
||||
|
||||
if (this.isClient) {
|
||||
// Clients can only create Refiller users
|
||||
defaultRole = 'Refiller';
|
||||
console.log('Client creating user - default role: Refiller');
|
||||
} else if (this.availableRoles.length > 0) {
|
||||
// For admins, use first available role as default
|
||||
defaultRole = this.availableRoles[0].value;
|
||||
console.log('Admin creating user - default role:', defaultRole);
|
||||
}
|
||||
|
||||
const newUser: User = {
|
||||
username: '',
|
||||
@ -380,17 +669,27 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.selectedUser = newUser;
|
||||
|
||||
// Reset form and set defaults
|
||||
this.selectedUserForm.reset();
|
||||
this.selectedUserForm.patchValue({
|
||||
user_status: 'Active',
|
||||
roles: defaultRole
|
||||
roles: defaultRole,
|
||||
assigned_to: ''
|
||||
});
|
||||
|
||||
this.selectedMachineIds = [];
|
||||
|
||||
// For new users, password is required
|
||||
this.selectedUserForm.get('password')?.setValidators([Validators.required, Validators.minLength(6)]);
|
||||
this.selectedUserForm.get('password')?.updateValueAndValidity();
|
||||
|
||||
// Scroll to top
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
this._changeDetectorRef.markForCheck();
|
||||
|
||||
// Debug form state
|
||||
this.debugFormValidation();
|
||||
}
|
||||
|
||||
onPhotoSelected(event: any): void {
|
||||
@ -520,14 +819,30 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
updateSelectedUser(): void {
|
||||
// Debug form before submission
|
||||
this.debugFormValidation();
|
||||
|
||||
if (!this.selectedUserForm.valid) {
|
||||
console.warn('Form is invalid, cannot submit');
|
||||
alert('Please fill in all required fields correctly.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.rolesLoaded) {
|
||||
console.warn('Roles not loaded yet');
|
||||
alert('Please wait for roles to load.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
const formValue = this.selectedUserForm.value;
|
||||
|
||||
console.log('Submitting user with role:', formValue.roles);
|
||||
console.log('Assigned to client:', formValue.assigned_to);
|
||||
console.log('Selected machines:', this.selectedMachineIds);
|
||||
|
||||
if (this.selectedUser?.id) {
|
||||
// Update existing user
|
||||
this._userService.updateUser(
|
||||
this.selectedUser.id,
|
||||
formValue,
|
||||
@ -538,18 +853,40 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe(
|
||||
() => {
|
||||
this.loadUsers();
|
||||
this.showFlashMessage('success');
|
||||
this.closeDetails();
|
||||
this.isLoading = false;
|
||||
console.log('✓ User updated successfully');
|
||||
|
||||
// Update machine assignments if Refiller
|
||||
if (formValue.roles === 'Refiller' && this.isAdmin && this.selectedMachineIds.length > 0) {
|
||||
this._userService.updateRefillerMachines(this.selectedUser!.id!, this.selectedMachineIds)
|
||||
.subscribe({
|
||||
next: (machineResponse) => {
|
||||
console.log('Machines updated:', machineResponse);
|
||||
this.loadUsers();
|
||||
this.showFlashMessage('success');
|
||||
this.closeDetails();
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error updating machines:', err);
|
||||
this.showFlashMessage('error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.loadUsers();
|
||||
this.showFlashMessage('success');
|
||||
this.closeDetails();
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error updating user:', error);
|
||||
console.error('✗ Error updating user:', error);
|
||||
this.showFlashMessage('error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Create new user
|
||||
this._userService.addUser(
|
||||
formValue,
|
||||
this.selectedPhoto || undefined,
|
||||
@ -558,14 +895,35 @@ export class UserComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.pipe(takeUntil(this._unsubscribeAll))
|
||||
.subscribe(
|
||||
() => {
|
||||
this.loadUsers();
|
||||
this.showFlashMessage('success');
|
||||
this.closeDetails();
|
||||
this.isLoading = false;
|
||||
(created) => {
|
||||
console.log('✓ User created successfully');
|
||||
|
||||
// Assign machines if Refiller
|
||||
if (formValue.roles === 'Refiller' && this.isAdmin && this.selectedMachineIds.length > 0) {
|
||||
this._userService.assignMachinesToRefiller(created.id!, this.selectedMachineIds)
|
||||
.subscribe({
|
||||
next: (machineResponse) => {
|
||||
console.log('Machines assigned:', machineResponse);
|
||||
this.loadUsers();
|
||||
this.showFlashMessage('success');
|
||||
this.closeDetails();
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error assigning machines:', err);
|
||||
this.showFlashMessage('error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.loadUsers();
|
||||
this.showFlashMessage('success');
|
||||
this.closeDetails();
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error creating user:', error);
|
||||
console.error('✗ Error creating user:', error);
|
||||
this.showFlashMessage('error');
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// user.service.ts - COMPLETE FIXED VERSION
|
||||
// user.service.ts - WITH OPTION 3 ASSIGNMENT METHODS
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
@ -27,6 +27,21 @@ export interface User {
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
machines?: any[];
|
||||
|
||||
// ⭐ Created by fields (automatic from backend)
|
||||
created_by?: number;
|
||||
created_by_username?: string;
|
||||
|
||||
// ⭐ NEW: Option 3 - Assignment fields
|
||||
assigned_to?: number | null;
|
||||
assigned_to_username?: string;
|
||||
assigned_machines?: Array<{
|
||||
machine_id: string;
|
||||
machine_model: string;
|
||||
branch_name: string;
|
||||
operation_status: string;
|
||||
}>;
|
||||
assigned_machines_count?: number;
|
||||
}
|
||||
|
||||
export interface DocumentWithMetadata {
|
||||
@ -35,6 +50,21 @@ export interface DocumentWithMetadata {
|
||||
documentTypeOther?: string;
|
||||
}
|
||||
|
||||
// ⭐ NEW: Client interface for assignment dropdown
|
||||
export interface Client {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// ⭐ NEW: Machine interface for assignment
|
||||
export interface Machine {
|
||||
machine_id: string;
|
||||
machine_model: string;
|
||||
branch_name: string;
|
||||
operation_status: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@ -70,6 +100,40 @@ export class UserService {
|
||||
return this.http.get<User[]>(this.BaseUrl);
|
||||
}
|
||||
|
||||
// ⭐ NEW: Get Clients for assignment dropdown
|
||||
getClients(): Observable<Client[]> {
|
||||
return this.http.get<Client[]>(`${this.backendBaseUrl}/clients`);
|
||||
}
|
||||
|
||||
// ⭐ NEW: Get machines for a specific client
|
||||
getClientMachines(clientId: number): Observable<Machine[]> {
|
||||
return this.http.get<Machine[]>(`${this.backendBaseUrl}/clients/${clientId}/machines`);
|
||||
}
|
||||
|
||||
// ⭐ NEW: Assign machines to Refiller (POST - for new user)
|
||||
assignMachinesToRefiller(refillerId: number, machineIds: string[]): Observable<any> {
|
||||
return this.http.post(`${this.backendBaseUrl}/refillers/${refillerId}/machines`, {
|
||||
machine_ids: machineIds
|
||||
});
|
||||
}
|
||||
|
||||
// ⭐ NEW: Update Refiller machines (PUT - for existing user)
|
||||
updateRefillerMachines(refillerId: number, machineIds: string[]): Observable<any> {
|
||||
return this.http.put(`${this.backendBaseUrl}/refillers/${refillerId}/machines`, {
|
||||
machine_ids: machineIds
|
||||
});
|
||||
}
|
||||
|
||||
// ⭐ NEW: Delete machine assignment
|
||||
deleteRefillerMachine(refillerId: number, machineId: string): Observable<any> {
|
||||
return this.http.delete(`${this.backendBaseUrl}/refillers/${refillerId}/machines/${machineId}`);
|
||||
}
|
||||
|
||||
// ⭐ NEW: Get machines assigned to a Refiller
|
||||
getRefillerMachines(refillerId: number): Observable<Machine[]> {
|
||||
return this.http.get<Machine[]>(`${this.backendBaseUrl}/refillers/${refillerId}/machines`);
|
||||
}
|
||||
|
||||
addUser(
|
||||
user: Partial<User>,
|
||||
photoFile?: File,
|
||||
@ -77,6 +141,10 @@ export class UserService {
|
||||
documentFiles?: DocumentWithMetadata[]
|
||||
): Observable<User> {
|
||||
const formData = this.createFormData(user, photoFile, logoFile, documentFiles);
|
||||
|
||||
// ⭐ NOTE: Don't add created_by to FormData
|
||||
// Backend automatically extracts it from JWT token
|
||||
|
||||
return this.http.post<User>(this.BaseUrl, formData);
|
||||
}
|
||||
|
||||
@ -89,6 +157,10 @@ export class UserService {
|
||||
): Observable<User> {
|
||||
const url = `${this.BaseUrl}/${id}`;
|
||||
const formData = this.createFormData(user, photoFile, logoFile, documentFiles);
|
||||
|
||||
// ⭐ NOTE: Don't update created_by
|
||||
// It's set once during creation and shouldn't change
|
||||
|
||||
console.log('PUT Request URL:', url);
|
||||
return this.http.put<User>(url, formData);
|
||||
}
|
||||
@ -123,6 +195,14 @@ export class UserService {
|
||||
if (user.user_status) formData.append('user_status', user.user_status);
|
||||
if (user.password) formData.append('password', user.password);
|
||||
|
||||
// ⭐ NEW: Add assigned_to for client assignment
|
||||
if (user.assigned_to !== undefined) {
|
||||
formData.append('assigned_to', user.assigned_to ? user.assigned_to.toString() : '');
|
||||
}
|
||||
|
||||
// ⭐ NOTE: created_by is NOT added here
|
||||
// Backend extracts it from JWT token automatically
|
||||
|
||||
// Append photo file
|
||||
if (photoFile) {
|
||||
formData.append('photo', photoFile, photoFile.name);
|
||||
|
||||
@ -103,5 +103,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": "15dead7e-04a1-4f40-8a32-c1cb1a5d23a0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user