Integrated employee management

This commit is contained in:
Nguyen Ngo
2025-08-27 12:46:32 -04:00
parent 5e565614d8
commit 5d1b6abaae
9 changed files with 2718 additions and 5 deletions
+334 -3
View File
@@ -195,7 +195,7 @@ STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager']
# Import and initialize models # Import and initialize models
from models import set_db from models import set_db
User, QRCode, QRCodeStyle, Project, AttendanceData = set_db(db) User, QRCode, QRCodeStyle, Project, AttendanceData, Employee = set_db(db)
# Initialize the logging system # Initialize the logging system
logger_handler = AppLogger(app, db) logger_handler = AppLogger(app, db)
@@ -1597,7 +1597,7 @@ def dashboard():
flash('Error loading dashboard. Please try again.', 'error') flash('Error loading dashboard. Please try again.', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
# User management routes # USER MANAGEMENT ROUTES
@app.route('/profile', methods=['GET', 'POST']) @app.route('/profile', methods=['GET', 'POST'])
@login_required @login_required
@log_user_activity('profile_update') @log_user_activity('profile_update')
@@ -3012,7 +3012,7 @@ def api_active_projects():
'error': 'Failed to fetch projects' 'error': 'Failed to fetch projects'
}), 500 }), 500
# QR code management routes # QR CODE MANAGEMENT ROUTES
@app.route('/qr-codes/create', methods=['GET', 'POST']) @app.route('/qr-codes/create', methods=['GET', 'POST'])
@login_required @login_required
@log_database_operations('qr_code_creation') @log_database_operations('qr_code_creation')
@@ -5679,6 +5679,337 @@ def export_statistics():
flash('Error loading statistics. Please try again.', 'error') flash('Error loading statistics. Please try again.', 'error')
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
# EMPLOYEE MANAGEMENT ROUTES
@app.route('/employees')
@admin_required
def employees():
"""Display employee management page with search and pagination"""
try:
# Log user accessing employee management
try:
logger_handler.logger.info(f"User {session['username']} accessed employee management list")
except Exception as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
# Get search parameters
search = request.args.get('search', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 20 # Number of employees per page
# Build query based on search
query = Employee.query
if search:
search_pattern = f"%{search}%"
query = query.filter(
db.or_(
Employee.firstName.like(search_pattern),
Employee.lastName.like(search_pattern),
Employee.title.like(search_pattern),
Employee.id.like(search_pattern)
)
)
# Order by first name, then last name
query = query.order_by(Employee.firstName, Employee.lastName)
# Paginate results
employees = query.paginate(
page=page,
per_page=per_page,
error_out=False
)
# Get summary statistics
total_employees = Employee.query.count()
employees_with_title = Employee.query.filter(Employee.title.isnot(None)).filter(Employee.title != '').count()
unique_titles = db.session.query(Employee.title).filter(Employee.title.isnot(None)).filter(Employee.title != '').distinct().count()
stats = {
'total_employees': total_employees,
'employees_with_title': employees_with_title,
'unique_titles': unique_titles,
'search_results': employees.total if search else total_employees
}
return render_template('employees.html',
employees=employees,
search=search,
stats=stats)
except Exception as e:
logger_handler.log_database_error('employee_list', e)
flash('Error loading employee list. Please try again.', 'error')
return redirect(url_for('dashboard'))
@app.route('/employees/create', methods=['GET', 'POST'])
@admin_required
@log_database_operations('employee_creation')
def create_employee():
"""Create new employee (Admin only)"""
if request.method == 'POST':
try:
# Get form data
employee_id = request.form['employee_id'].strip()
first_name = request.form['first_name'].strip()
last_name = request.form['last_name'].strip()
title = request.form.get('title', '').strip()
contract_id = request.form.get('contract_id', '1').strip()
# Validate required fields
if not all([employee_id, first_name, last_name]):
flash('Employee ID, First Name, and Last Name are required.', 'error')
return render_template('create_employee.html')
# Validate employee ID is numeric
try:
employee_id_int = int(employee_id)
contract_id_int = int(contract_id)
except ValueError:
flash('Employee ID and Contract ID must be numeric.', 'error')
return render_template('create_employee.html')
# Check if employee ID already exists
existing_employee = Employee.query.filter_by(id=employee_id_int).first()
if existing_employee:
flash(f'Employee with ID {employee_id} already exists.', 'error')
return render_template('create_employee.html')
# Create new employee
new_employee = Employee(
id=employee_id_int,
firstName=first_name,
lastName=last_name,
title=title if title else None,
contractId=contract_id_int
)
db.session.add(new_employee)
db.session.commit()
# Log employee creation
try:
logger_handler.logger.info(f"Admin user {session['username']} created new employee: {employee_id_int} - {first_name} {last_name}")
except Exception as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
flash(f'Employee "{first_name} {last_name}" (ID: {employee_id}) created successfully.', 'success')
return redirect(url_for('employees'))
except Exception as e:
db.session.rollback()
logger_handler.log_database_error('employee_creation', e)
flash('Failed to create employee. Please try again.', 'error')
return render_template('create_employee.html')
return render_template('create_employee.html')
@app.route('/employees/<int:employee_index>/edit', methods=['GET', 'POST'])
@admin_required
@log_database_operations('employee_update')
def edit_employee(employee_index):
"""Edit existing employee (Admin only)"""
try:
# Get employee by index (primary key)
employee = Employee.query.get_or_404(employee_index)
if request.method == 'POST':
# Get form data
employee_id = request.form['employee_id'].strip()
first_name = request.form['first_name'].strip()
last_name = request.form['last_name'].strip()
title = request.form.get('title', '').strip()
contract_id = request.form.get('contract_id', '1').strip()
stats = {
'total_employees': 0,
'employees_with_title': 0,
'unique_titles': 0,
'search_results': 0
}
# Validate required fields
if not all([employee_id, first_name, last_name]):
flash('Employee ID, First Name, and Last Name are required.', 'error')
return render_template('edit_employee.html', employee=employee)
# Validate numeric fields
try:
employee_id_int = int(employee_id)
contract_id_int = int(contract_id)
except ValueError:
flash('Employee ID and Contract ID must be numeric.', 'error')
return render_template('edit_employee.html', employee=employee)
# Check if employee ID already exists (but not for this employee)
existing_employee = Employee.query.filter_by(id=employee_id_int).first()
if existing_employee and existing_employee.index != employee.index:
flash(f'Employee with ID {employee_id} already exists.', 'error')
return render_template('edit_employee.html', employee=employee, stats=stats)
# Store original values for logging
original_data = {
'id': employee.id,
'firstName': employee.firstName,
'lastName': employee.lastName,
'title': employee.title,
'contractId': employee.contractId
}
# Update employee data
employee.id = employee_id_int
employee.firstName = first_name
employee.lastName = last_name
employee.title = title if title else None
employee.contractId = contract_id_int
db.session.commit()
# Log employee update
try:
logger_handler.logger.info(f"Admin user {session['username']} updated employee: {employee_index} - {first_name} {last_name}")
except Exception as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
flash(f'Employee "{first_name} {last_name}" updated successfully.', 'success')
return redirect(url_for('employees'))
return render_template('edit_employee.html', employee=employee)
except Exception as e:
db.session.rollback()
logger_handler.log_database_error('employee_update', e)
flash('Error updating employee. Please try again.', 'error')
return redirect(url_for('employees'))
@app.route('/employees/<int:employee_index>/delete', methods=['POST'])
@admin_required
@log_database_operations('employee_deletion')
def delete_employee(employee_index):
"""Delete employee (Admin only)"""
try:
# Get employee by index (primary key)
employee = Employee.query.get_or_404(employee_index)
# Store employee data for logging before deletion
employee_data = {
'index': employee.index,
'id': employee.id,
'firstName': employee.firstName,
'lastName': employee.lastName,
'title': employee.title,
'contractId': employee.contractId
}
# Check if employee has attendance records
from models.attendance import AttendanceData
attendance_count = AttendanceData.query.filter_by(employee_id=str(employee.id)).count()
if attendance_count > 0:
flash(f'Cannot delete employee "{employee.full_name}". Employee has {attendance_count} attendance records. Please contact system administrator.', 'error')
return redirect(url_for('employees'))
db.session.delete(employee)
db.session.commit()
# Log employee deletion
try:
logger_handler.logger.info(f"Admin user {session['username']} deleted employee: {employee_data['firstName']} {employee_data['lastName']} (ID: {employee_data['id']})")
except Exception as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
flash(f'Employee "{employee_data["firstName"]} {employee_data["lastName"]}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
logger_handler.log_database_error('employee_deletion', e)
flash('Error deleting employee. Please try again.', 'error')
return redirect(url_for('employees'))
@app.route('/api/employees/search')
@login_required
def api_employees_search():
"""API endpoint for employee search (for AJAX)"""
try:
search = request.args.get('q', '').strip()
limit = request.args.get('limit', 10, type=int)
if not search:
return jsonify({'employees': []})
employees = Employee.search_employees(search)[:limit]
result = {
'employees': [emp.to_dict() for emp in employees]
}
return jsonify(result)
except Exception as e:
logger_handler.log_database_error('employee_search_api', e)
return jsonify({'error': 'Search failed'}), 500
@app.route('/employees/<int:employee_index>')
@admin_required
def employee_detail(employee_index):
"""View employee details with attendance summary"""
try:
# Get employee by index (primary key)
employee = Employee.query.get_or_404(employee_index)
# Get attendance statistics for this employee
from models.attendance import AttendanceData
# Total attendance records
total_attendance = AttendanceData.query.filter_by(employee_id=str(employee.id)).count()
# Recent attendance (last 30 days)
from datetime import datetime, timedelta
thirty_days_ago = datetime.now() - timedelta(days=30)
recent_attendance = AttendanceData.query.filter(
AttendanceData.employee_id == str(employee.id),
AttendanceData.check_in_date >= thirty_days_ago.date()
).count()
# Most recent attendance record
latest_attendance = AttendanceData.query.filter_by(employee_id=str(employee.id)).order_by(
AttendanceData.check_in_date.desc(),
AttendanceData.check_in_time.desc()
).first()
# Get unique projects this employee has attended
unique_projects = db.session.query(Project).join(
QRCode, Project.id == QRCode.project_id
).join(
AttendanceData, QRCode.id == AttendanceData.qr_code_id
).filter(
AttendanceData.employee_id == str(employee.id)
).distinct().all()
attendance_stats = {
'total_attendance': total_attendance,
'recent_attendance': recent_attendance,
'latest_attendance': latest_attendance,
'unique_projects': len(unique_projects),
'projects': unique_projects
}
# Log employee detail view
try:
logger_handler.logger.info(f"User {session['username']} viewed employee detail: {employee.full_name} (ID: {employee.id})")
except Exception as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
return render_template('employee_detail.html',
employee=employee,
attendance_stats=attendance_stats)
except Exception as e:
logger_handler.log_database_error('employee_detail', e)
flash('Error loading employee details. Please try again.', 'error')
return redirect(url_for('employees'))
# Jinja2 filters for better template functionality # Jinja2 filters for better template functionality
@app.template_filter('days_since') @app.template_filter('days_since')
def days_since_filter(date): def days_since_filter(date):
+2 -1
View File
@@ -17,5 +17,6 @@ def set_db(database):
from .qrcode import QRCode, QRCodeStyle from .qrcode import QRCode, QRCodeStyle
from .project import Project from .project import Project
from .attendance import AttendanceData from .attendance import AttendanceData
from .employee import Employee
return User, QRCode, QRCodeStyle, Project, AttendanceData return User, QRCode, QRCodeStyle, Project, AttendanceData, Employee
+72
View File
@@ -0,0 +1,72 @@
"""
Employee Model for QR Attendance Management System
=================================================
Employee model to manage employee data from the external employee table.
This model interfaces with the existing employee table structure.
"""
from datetime import datetime
from . import base
class Employee(base.db.Model):
"""
Employee model to manage employee records
Maps to existing employee table structure
"""
__tablename__ = 'employee'
# Map to existing table structure from employee.sql
index = base.db.Column('index', base.db.BigInteger, primary_key=True, autoincrement=True)
id = base.db.Column('id', base.db.BigInteger, nullable=False, unique=True)
firstName = base.db.Column('firstName', base.db.String(50), nullable=False)
lastName = base.db.Column('lastName', base.db.String(50), nullable=False)
title = base.db.Column('title', base.db.String(20), nullable=True)
contractId = base.db.Column('contractId', base.db.BigInteger, nullable=False, default=1)
def __repr__(self):
return f'<Employee {self.firstName} {self.lastName} (ID: {self.id})>'
@property
def full_name(self):
"""Get employee's full name"""
return f"{self.firstName} {self.lastName}"
@property
def display_title(self):
"""Get formatted title for display"""
return self.title if self.title else "No Title"
@classmethod
def get_by_employee_id(cls, employee_id):
"""Get employee by their ID (not primary key index)"""
return cls.query.filter_by(id=employee_id).first()
@classmethod
def search_employees(cls, search_term):
"""Search employees by name, ID, or title"""
if not search_term:
return cls.query.all()
search_pattern = f"%{search_term}%"
return cls.query.filter(
base.db.or_(
cls.firstName.like(search_pattern),
cls.lastName.like(search_pattern),
cls.title.like(search_pattern),
cls.id.like(search_pattern)
)
).all()
def to_dict(self):
"""Convert employee to dictionary for JSON serialization"""
return {
'index': self.index,
'id': self.id,
'firstName': self.firstName,
'lastName': self.lastName,
'full_name': self.full_name,
'title': self.title,
'display_title': self.display_title,
'contractId': self.contractId
}
+712
View File
@@ -0,0 +1,712 @@
/**
* Employee Management Page Styles
* static/css/employees.css
*/
/* Main Container */
.employees-page {
max-width: 1600px;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
background: #f8fafc;
}
/* Sidebar Layout Compatibility */
body.has-sidebar .employees-page {
margin-left: 0;
padding-left: 0;
}
.main-wrapper .employees-page {
padding: 2rem;
max-width: 1600px;
margin: 0 auto;
min-height: 100vh;
background: #f8fafc;
}
/* Page Header */
.employees-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
padding: 1.5rem;
background: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
}
.employees-header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #2563eb, #1d4ed8);
}
.header-navigation {
margin-bottom: 1rem;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f1f5f9;
color: #475569;
text-decoration: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid #cbd5e1;
transition: all 0.2s ease-in-out;
}
.back-button:hover {
background: #e2e8f0;
color: #334155;
transform: translateX(-2px);
text-decoration: none;
}
.header-content h1 {
font-size: 1.875rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-content h1 i {
color: #2563eb;
}
.header-description {
color: #64748b;
font-size: 1rem;
margin: 0;
}
.header-actions {
display: flex;
gap: 1rem;
}
/* Statistics Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: #ffffff;
padding: 1.5rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.2s ease-in-out;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px 0 rgba(0, 0, 0, 0.1);
}
.stat-card.search-results {
border: 2px solid #fbbf24;
background: #fefbf2;
}
.stat-icon {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #ffffff;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.stat-card.search-results .stat-icon {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
.stat-info h3 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 0.25rem;
}
.stat-info p {
color: #64748b;
font-size: 0.875rem;
margin: 0;
}
/* Search Section */
.search-section {
background: #ffffff;
padding: 1.5rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.search-form {
display: flex;
justify-content: center;
}
.search-input-group {
position: relative;
display: flex;
max-width: 600px;
width: 100%;
}
.search-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem 0 0 0.5rem;
font-size: 1rem;
background: #ffffff;
transition: border-color 0.2s ease-in-out;
}
.search-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.search-btn {
padding: 0.75rem 1.25rem;
background: #2563eb;
color: #ffffff;
border: none;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease-in-out;
}
.clear-search-btn {
padding: 0.75rem 1.25rem;
background: #dc2626;
color: #ffffff;
text-decoration: none;
border-radius: 0 0.5rem 0.5rem 0;
display: flex;
align-items: center;
transition: background-color 0.2s ease-in-out;
}
.search-btn {
border-radius: 0 0.5rem 0.5rem 0;
}
.search-input-group:has(.clear-search-btn) .search-btn {
border-radius: 0;
}
.search-btn:hover {
background: #1d4ed8;
}
.clear-search-btn:hover {
background: #b91c1c;
}
/* Employee Table Container */
.employees-table-container {
background: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.table-header {
padding: 1.5rem;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8fafc;
}
.table-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.table-info {
color: #64748b;
font-size: 0.875rem;
}
/* Table Styles */
.table-responsive {
overflow-x: auto;
}
.employees-table {
width: 100%;
border-collapse: collapse;
}
.employees-table thead {
background: #f8fafc;
}
.employees-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 1px solid #e2e8f0;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.employees-table tbody tr {
transition: background-color 0.2s ease-in-out;
}
.employees-table tbody tr:hover {
background: #f8fafc;
}
.employees-table td {
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
/* Table Cell Specific Styles */
.row-number {
font-weight: 500;
color: #64748b;
width: 60px;
}
.employee-id .id-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
background: #dbeafe;
color: #1e40af;
border-radius: 0.375rem;
font-weight: 600;
font-size: 0.875rem;
}
.employee-name {
min-width: 200px;
}
.name-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar i {
font-size: 1.25rem;
}
.name-details h4 {
font-weight: 600;
color: #0f172a;
margin: 0 0 0.25rem 0;
}
.name-details p {
font-size: 0.875rem;
color: #64748b;
margin: 0;
}
.employee-title .title-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
background: #ecfdf5;
color: #065f46;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
}
.employee-title .no-title {
color: #9ca3af;
font-style: italic;
font-size: 0.875rem;
}
.contract-id .contract-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
}
/* Action Buttons */
.actions {
width: 140px;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
border: none;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.btn-primary {
background: #2563eb;
color: #ffffff;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-info {
background: #0891b2;
color: #ffffff;
}
.btn-info:hover {
background: #0e7490;
}
.btn-warning {
background: #f59e0b;
color: #ffffff;
}
.btn-warning:hover {
background: #d97706;
}
.btn-danger {
background: #dc2626;
color: #ffffff;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-secondary {
background: #6b7280;
color: #ffffff;
}
.btn-secondary:hover {
background: #4b5563;
}
/* Pagination */
.pagination-container {
padding: 1.5rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.pagination-nav {
display: flex;
justify-content: center;
}
.pagination {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 0.25rem;
}
.pagination-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
color: #374151;
text-decoration: none;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
transition: all 0.2s ease-in-out;
}
.pagination-link:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.pagination-link.current {
background: #2563eb;
color: #ffffff;
border-color: #2563eb;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #6b7280;
}
.empty-icon {
font-size: 4rem;
color: #d1d5db;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.empty-state p {
font-size: 1rem;
margin: 0;
}
.empty-state a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
}
.empty-state a:hover {
text-decoration: underline;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.modal-content {
background: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 90%;
max-width: 500px;
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.modal-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
color: #dc2626;
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.modal-header h3 i {
color: #f59e0b;
}
.close-modal {
background: none;
border: none;
font-size: 1.25rem;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: color 0.2s ease-in-out;
}
.close-modal:hover {
color: #374151;
background: #f3f4f6;
}
.modal-body {
padding: 1.5rem;
}
.modal-body p {
margin-bottom: 1rem;
color: #374151;
}
.modal-body p:last-child {
margin-bottom: 0;
}
.warning-text {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.375rem;
color: #b91c1c;
font-size: 0.875rem;
}
.warning-text i {
color: #f59e0b;
}
.modal-footer {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding: 1.5rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
/* Search Highlighting */
mark {
background: #fef08a;
color: #854d0e;
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
font-weight: 600;
}
/* Responsive Design */
@media (max-width: 768px) {
.employees-page {
padding: 1rem;
}
.employees-header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.stats-grid {
grid-template-columns: 1fr;
}
.table-header {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.employees-table {
font-size: 0.875rem;
}
.employees-table th,
.employees-table td {
padding: 0.75rem 0.5rem;
}
.name-container {
flex-direction: column;
text-align: center;
gap: 0.5rem;
}
.action-buttons {
flex-direction: column;
gap: 0.25rem;
}
.modal-content {
width: 95%;
margin: 1rem;
}
.modal-footer {
flex-direction: column;
}
}
+5 -1
View File
@@ -61,9 +61,13 @@
<span class="menu-text">Projects</span> <span class="menu-text">Projects</span>
</a> </a>
<a href="{{ url_for('users') }}" class="menu-item"> <a href="{{ url_for('users') }}" class="menu-item">
<i class="fas fa-users"></i> <i class="fas fa-user-cog"></i>
<span class="menu-text">Users</span> <span class="menu-text">Users</span>
</a> </a>
<a href="{{ url_for('employees') }}" class="menu-item">
<i class="fas fa-user"></i>
<span class="menu-text">Employees</span>
</a>
<a href="{{ url_for('attendance_report') }}" class="menu-item"> <a href="{{ url_for('attendance_report') }}" class="menu-item">
<i class="fas fa-chart-line"></i> <i class="fas fa-chart-line"></i>
<span class="menu-text">Reports</span> <span class="menu-text">Reports</span>
+324
View File
@@ -0,0 +1,324 @@
{% extends "base_authenticated.html" %}
{% set page_title = "Add New Employee" %}
{% block title %}{{ page_title }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/employees.css') }}">
<style>
.form-container {
max-width: 600px;
margin: 0 auto;
background: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.form-header {
background: #f8fafc;
padding: 1.5rem;
border-bottom: 1px solid #e2e8f0;
position: relative;
}
.form-header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #10b981, #059669);
}
.form-header h2 {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.5rem;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.form-header h2 i {
color: #10b981;
}
.form-body {
padding: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-label.required::after {
content: " *";
color: #dc2626;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
background: #ffffff;
}
.form-input:focus {
outline: none;
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
.form-input.error {
border-color: #dc2626;
}
.form-help {
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.25rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1.5rem;
border-top: 1px solid #e2e8f0;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="employees-page">
<!-- Page Header -->
<div class="employees-header">
<div class="header-content">
<div class="header-navigation">
<a href="{{ url_for('employees') }}" class="back-button">
<i class="fas fa-arrow-left"></i>
Back to Employees
</a>
</div>
<h1><i class="fas fa-user-plus"></i> Add New Employee</h1>
<p class="header-description">
Create a new employee record in the system
</p>
</div>
</div>
<!-- Form Container -->
<div class="form-container">
<div class="form-header">
<h2>
<i class="fas fa-user-plus"></i>
Employee Information
</h2>
</div>
<div class="form-body">
<form method="POST" id="createEmployeeForm">
<!-- Employee ID and Contract ID Row -->
<div class="form-row">
<div class="form-group">
<label for="employee_id" class="form-label required">Employee ID</label>
<input
type="number"
id="employee_id"
name="employee_id"
class="form-input"
required
min="1"
placeholder="Enter unique employee ID"
value="{{ request.form.get('employee_id', '') }}"
>
<div class="form-help">
Must be a unique numeric identifier
</div>
</div>
<div class="form-group">
<label for="contract_id" class="form-label">Contract ID</label>
<input
type="number"
id="contract_id"
name="contract_id"
class="form-input"
min="1"
value="{{ request.form.get('contract_id', '1') }}"
placeholder="Enter contract ID"
>
<div class="form-help">
Default is 1 if not specified
</div>
</div>
</div>
<!-- First Name and Last Name Row -->
<div class="form-row">
<div class="form-group">
<label for="first_name" class="form-label required">First Name</label>
<input
type="text"
id="first_name"
name="first_name"
class="form-input"
required
maxlength="50"
placeholder="Enter first name"
value="{{ request.form.get('first_name', '') }}"
>
</div>
<div class="form-group">
<label for="last_name" class="form-label required">Last Name</label>
<input
type="text"
id="last_name"
name="last_name"
class="form-input"
required
maxlength="50"
placeholder="Enter last name"
value="{{ request.form.get('last_name', '') }}"
>
</div>
</div>
<!-- Job Title -->
<div class="form-group">
<label for="title" class="form-label">Job Title</label>
<input
type="text"
id="title"
name="title"
class="form-input"
maxlength="20"
placeholder="Enter job title (optional)"
value="{{ request.form.get('title', '') }}"
>
<div class="form-help">
Optional field, maximum 20 characters
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<a href="{{ url_for('employees') }}" class="btn btn-secondary">
<i class="fas fa-times"></i>
Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
Create Employee
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('createEmployeeForm');
const employeeIdInput = document.getElementById('employee_id');
const firstNameInput = document.getElementById('first_name');
const lastNameInput = document.getElementById('last_name');
// Form validation
form.addEventListener('submit', function(e) {
let isValid = true;
// Clear previous error states
document.querySelectorAll('.form-input').forEach(input => {
input.classList.remove('error');
});
// Validate Employee ID
if (!employeeIdInput.value.trim()) {
employeeIdInput.classList.add('error');
isValid = false;
}
// Validate First Name
if (!firstNameInput.value.trim()) {
firstNameInput.classList.add('error');
isValid = false;
}
// Validate Last Name
if (!lastNameInput.value.trim()) {
lastNameInput.classList.add('error');
isValid = false;
}
if (!isValid) {
e.preventDefault();
alert('Please fill in all required fields.');
}
});
// Auto-capitalize names
[firstNameInput, lastNameInput].forEach(input => {
input.addEventListener('input', function() {
const words = this.value.split(' ');
const capitalizedWords = words.map(word => {
if (word.length > 0) {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
return word;
});
this.value = capitalizedWords.join(' ');
});
});
// Employee ID validation
employeeIdInput.addEventListener('input', function() {
// Remove any non-numeric characters
this.value = this.value.replace(/[^0-9]/g, '');
// Remove leading zeros
if (this.value.length > 1) {
this.value = this.value.replace(/^0+/, '');
}
});
});
</script>
{% endblock %}
+374
View File
@@ -0,0 +1,374 @@
{% extends "base_authenticated.html" %}
{% set page_title = "Employee Management" %}
{% block title %}{{ page_title }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/employees.css') }}">
{% endblock %}
{% block content %}
<div class="employees-page">
<!-- Page Header -->
<div class="employees-header">
<div class="header-content">
<div class="header-navigation">
<a href="{{ url_for('dashboard') }}" class="back-button">
<i class="fas fa-arrow-left"></i>
Back to Dashboard
</a>
</div>
<h1><i class="fas fa-users"></i> Employee Management</h1>
<p class="header-description">
Manage employee records and view attendance statistics
</p>
</div>
<div class="header-actions">
{% if session.user_role == 'admin' %}
<a href="{{ url_for('create_employee') }}" class="btn btn-primary">
<i class="fas fa-plus"></i>
Add Employee
</a>
{% endif %}
</div>
</div>
<!-- Statistics Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_employees }}</h3>
<p>Total Employees</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-id-badge"></i>
</div>
<div class="stat-info">
<h3>{{ stats.employees_with_title }}</h3>
<p>With Job Titles</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-briefcase"></i>
</div>
<div class="stat-info">
<h3>{{ stats.unique_titles }}</h3>
<p>Unique Job Titles</p>
</div>
</div>
{% if search %}
<div class="stat-card search-results">
<div class="stat-icon">
<i class="fas fa-search"></i>
</div>
<div class="stat-info">
<h3>{{ stats.search_results }}</h3>
<p>Search Results</p>
</div>
</div>
{% endif %}
</div>
<!-- Search and Filter Section -->
<div class="search-section">
<form method="GET" class="search-form">
<div class="search-input-group">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search by name, employee ID, or job title..."
class="search-input"
autocomplete="off"
>
<button type="submit" class="search-btn">
<i class="fas fa-search"></i>
</button>
{% if search %}
<a href="{{ url_for('employees') }}" class="clear-search-btn" title="Clear search">
<i class="fas fa-times"></i>
</a>
{% endif %}
</div>
</form>
</div>
<!-- Employee Table -->
<div class="employees-table-container">
<div class="table-header">
<h2>
{% if search %}
Search Results for "{{ search }}"
{% else %}
All Employees
{% endif %}
</h2>
<div class="table-info">
Showing {{ employees.items|length }} of {{ employees.total }} employees
{% if employees.pages > 1 %}
(Page {{ employees.page }} of {{ employees.pages }})
{% endif %}
</div>
</div>
<div class="table-responsive">
<table class="employees-table">
<thead>
<tr>
<th>#</th>
<th>Employee ID</th>
<th>Name</th>
<th>Job Title</th>
<th>Contract ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for employee in employees.items %}
<tr class="employee-row" data-employee-id="{{ employee.id }}">
<td class="row-number">{{ loop.index + (employees.page - 1) * employees.per_page }}</td>
<td class="employee-id">
<span class="id-badge">{{ employee.id }}</span>
</td>
<td class="employee-name">
<div class="name-container">
<div class="avatar">
<i class="fas fa-user-circle"></i>
</div>
<div class="name-details">
<h4>{{ employee.full_name }}</h4>
<p>{{ employee.firstName }} {{ employee.lastName }}</p>
</div>
</div>
</td>
<td class="employee-title">
{% if employee.title %}
<span class="title-badge">{{ employee.title }}</span>
{% else %}
<span class="no-title">No Title</span>
{% endif %}
</td>
<td class="contract-id">
<span class="contract-badge">{{ employee.contractId }}</span>
</td>
<td class="actions">
<div class="action-buttons">
<a href="{{ url_for('employee_detail', employee_index=employee.index) }}"
class="btn btn-sm btn-info"
title="View Details">
<i class="fas fa-eye"></i>
</a>
{% if session.user_role == 'admin' %}
<a href="{{ url_for('edit_employee', employee_index=employee.index) }}"
class="btn btn-sm btn-warning"
title="Edit Employee">
<i class="fas fa-edit"></i>
</a>
<button class="btn btn-sm btn-danger delete-btn"
data-employee-index="{{ employee.index }}"
data-employee-name="{{ employee.full_name }}"
title="Delete Employee">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if employees.pages > 1 %}
<div class="pagination-container">
<nav class="pagination-nav">
<ul class="pagination">
<!-- Previous Page -->
{% if employees.has_prev %}
<li>
<a href="{{ url_for('employees', page=employees.prev_num, search=search) }}"
class="pagination-link">
<i class="fas fa-chevron-left"></i>
Previous
</a>
</li>
{% endif %}
<!-- Page Numbers -->
{% for page_num in employees.iter_pages() %}
{% if page_num %}
{% if page_num != employees.page %}
<li>
<a href="{{ url_for('employees', page=page_num, search=search) }}"
class="pagination-link">
{{ page_num }}
</a>
</li>
{% else %}
<li>
<span class="pagination-link current">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li>
<span class="pagination-link"></span>
</li>
{% endif %}
{% endfor %}
<!-- Next Page -->
{% if employees.has_next %}
<li>
<a href="{{ url_for('employees', page=employees.next_num, search=search) }}"
class="pagination-link">
Next
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
<!-- Empty State -->
{% if employees.total == 0 %}
<div class="empty-state">
<div class="empty-icon">
<i class="fas fa-users-slash"></i>
</div>
<h3>
{% if search %}
No employees found for "{{ search }}"
{% else %}
No employees found
{% endif %}
</h3>
<p>
{% if search %}
Try adjusting your search terms or <a href="{{ url_for('employees') }}">view all employees</a>.
{% else %}
Get started by <a href="{{ url_for('create_employee') }}">adding your first employee</a>.
{% endif %}
</p>
</div>
{% endif %}
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-exclamation-triangle"></i> Confirm Deletion</h3>
<button class="close-modal" data-modal="deleteModal">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete employee <strong id="deleteEmployeeName"></strong>?</p>
<p class="warning-text">
<i class="fas fa-warning"></i>
This action cannot be undone. The employee will be permanently removed from the system.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-modal="deleteModal">Cancel</button>
<form id="deleteForm" method="POST" style="display: inline;">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash"></i>
Delete Employee
</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Delete button functionality
const deleteButtons = document.querySelectorAll('.delete-btn');
const deleteModal = document.getElementById('deleteModal');
const deleteForm = document.getElementById('deleteForm');
const deleteEmployeeName = document.getElementById('deleteEmployeeName');
deleteButtons.forEach(button => {
button.addEventListener('click', function() {
const employeeIndex = this.getAttribute('data-employee-index');
const employeeName = this.getAttribute('data-employee-name');
deleteEmployeeName.textContent = employeeName;
deleteForm.action = `/employees/${employeeIndex}/delete`;
deleteModal.style.display = 'flex';
});
});
// Modal close functionality
document.querySelectorAll('[data-modal]').forEach(element => {
element.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal');
document.getElementById(modalId).style.display = 'none';
});
});
// Close modal when clicking outside
deleteModal.addEventListener('click', function(e) {
if (e.target === this) {
this.style.display = 'none';
}
});
// Search form auto-submit with debouncing
const searchInput = document.querySelector('.search-input');
let searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (this.value.length >= 3 || this.value.length === 0) {
this.form.submit();
}
}, 500);
});
// Highlight search terms
const searchTerm = "{{ search }}";
if (searchTerm) {
highlightSearchTerms(searchTerm);
}
function highlightSearchTerms(term) {
const elements = document.querySelectorAll('.employee-name h4, .employee-name p, .employee-title .title-badge, .employee-id .id-badge');
const regex = new RegExp(`(${term})`, 'gi');
elements.forEach(element => {
const text = element.textContent;
if (text.toLowerCase().includes(term.toLowerCase())) {
element.innerHTML = text.replace(regex, '<mark>$1</mark>');
}
});
}
});
</script>
{% endblock %}
+521
View File
@@ -0,0 +1,521 @@
{% extends "base_authenticated.html" %}
{% set page_title = "Employee Details - " + employee.full_name %}
{% block title %}{{ page_title }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/employees.css') }}">
<style>
.employee-detail-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
background: #f8fafc;
}
.employee-profile {
background: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 2rem;
}
.profile-header {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #ffffff;
padding: 2rem;
position: relative;
}
.profile-header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
}
.profile-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 1.5rem;
}
.profile-avatar {
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
backdrop-filter: blur(10px);
flex-shrink: 0;
}
.profile-info h1 {
font-size: 1.875rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.profile-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
opacity: 0.9;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.meta-item i {
width: 16px;
text-align: center;
}
.employee-actions {
position: absolute;
top: 1.5rem;
right: 1.5rem;
display: flex;
gap: 0.5rem;
}
.profile-body {
padding: 2rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.info-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 1.5rem;
}
.info-card h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #0f172a;
margin-bottom: 1rem;
}
.info-value {
font-size: 0.875rem;
color: #64748b;
}
.info-value strong {
color: #0f172a;
}
.attendance-stats {
margin-top: 2rem;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.stats-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: #ffffff;
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 1rem;
transition: transform 0.2s ease-in-out;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.stat-icon.total {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #ffffff;
}
.stat-icon.recent {
background: linear-gradient(135deg, #10b981, #059669);
color: #ffffff;
}
.stat-icon.projects {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #ffffff;
}
.stat-icon.latest {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: #ffffff;
}
.stat-info h3 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 0.25rem;
}
.stat-info p {
color: #64748b;
font-size: 0.875rem;
margin: 0;
}
.projects-list {
background: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.projects-header {
background: #f8fafc;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.projects-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.projects-body {
padding: 1.5rem;
}
.project-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
margin-bottom: 0.75rem;
transition: all 0.2s ease-in-out;
}
.project-item:last-child {
margin-bottom: 0;
}
.project-item:hover {
border-color: #2563eb;
background: #f8fafc;
}
.project-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #ffffff;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.project-info h4 {
font-weight: 600;
color: #0f172a;
margin-bottom: 0.25rem;
}
.project-info p {
font-size: 0.875rem;
color: #64748b;
margin: 0;
}
.no-projects {
text-align: center;
padding: 2rem;
color: #64748b;
}
.no-projects i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
@media (max-width: 768px) {
.employee-detail-page {
padding: 1rem;
}
.profile-content {
flex-direction: column;
text-align: center;
}
.employee-actions {
position: static;
justify-content: center;
margin-top: 1rem;
}
.info-grid,
.stats-grid {
grid-template-columns: 1fr;
}
.profile-meta {
grid-template-columns: 1fr;
text-align: left;
}
}
</style>
{% endblock %}
{% block content %}
<div class="employee-detail-page">
<!-- Navigation -->
<div class="employees-header" style="margin-bottom: 1rem;">
<div class="header-content">
<div class="header-navigation">
<a href="{{ url_for('employees') }}" class="back-button">
<i class="fas fa-arrow-left"></i>
Back to Employees
</a>
</div>
</div>
</div>
<!-- Employee Profile -->
<div class="employee-profile">
<div class="profile-header">
<div class="profile-content">
<div class="profile-avatar">
<i class="fas fa-user"></i>
</div>
<div class="profile-info">
<h1>{{ employee.full_name }}</h1>
<div class="profile-meta">
<div class="meta-item">
<i class="fas fa-id-card"></i>
Employee ID: {{ employee.id }}
</div>
<div class="meta-item">
<i class="fas fa-briefcase"></i>
{{ employee.display_title }}
</div>
<div class="meta-item">
<i class="fas fa-file-contract"></i>
Contract ID: {{ employee.contractId }}
</div>
{% if attendance_stats.latest_attendance %}
<div class="meta-item">
<i class="fas fa-clock"></i>
Last seen: {{ attendance_stats.latest_attendance.check_in_date.strftime('%B %d, %Y') }}
</div>
{% endif %}
</div>
</div>
</div>
{% if session.role == 'admin' %}
<div class="employee-actions">
<a href="{{ url_for('edit_employee', employee_index=employee.index) }}"
class="btn btn-warning btn-sm">
<i class="fas fa-edit"></i>
Edit
</a>
</div>
{% endif %}
</div>
<div class="profile-body">
<div class="info-grid">
<div class="info-card">
<h3>
<i class="fas fa-user"></i>
Personal Information
</h3>
<div class="info-value">
<p><strong>First Name:</strong> {{ employee.firstName }}</p>
<p><strong>Last Name:</strong> {{ employee.lastName }}</p>
<p><strong>Full Name:</strong> {{ employee.full_name }}</p>
</div>
</div>
<div class="info-card">
<h3>
<i class="fas fa-id-badge"></i>
Employment Details
</h3>
<div class="info-value">
<p><strong>Employee ID:</strong> {{ employee.id }}</p>
<p><strong>Job Title:</strong> {{ employee.display_title }}</p>
<p><strong>Contract ID:</strong> {{ employee.contractId }}</p>
</div>
</div>
<div class="info-card">
<h3>
<i class="fas fa-database"></i>
System Information
</h3>
<div class="info-value">
<p><strong>Database Index:</strong> {{ employee.index }}</p>
<p><strong>Record Status:</strong> Active</p>
<p><strong>Attendance Records:</strong> {{ attendance_stats.total_attendance }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Attendance Statistics -->
<div class="attendance-stats">
<div class="stats-header">
<h2>
<i class="fas fa-chart-bar"></i>
Attendance Statistics
</h2>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon total">
<i class="fas fa-calendar-check"></i>
</div>
<div class="stat-info">
<h3>{{ attendance_stats.total_attendance }}</h3>
<p>Total Attendance</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon recent">
<i class="fas fa-calendar-week"></i>
</div>
<div class="stat-info">
<h3>{{ attendance_stats.recent_attendance }}</h3>
<p>Last 30 Days</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon projects">
<i class="fas fa-project-diagram"></i>
</div>
<div class="stat-info">
<h3>{{ attendance_stats.unique_projects }}</h3>
<p>Unique Projects</p>
</div>
</div>
{% if attendance_stats.latest_attendance %}
<div class="stat-card">
<div class="stat-icon latest">
<i class="fas fa-clock"></i>
</div>
<div class="stat-info">
<h3>{{ attendance_stats.latest_attendance.check_in_date.strftime('%m/%d') }}</h3>
<p>Latest Check-in</p>
</div>
</div>
{% endif %}
</div>
<!-- Projects List -->
{% if attendance_stats.projects %}
<div class="projects-list">
<div class="projects-header">
<h3>
<i class="fas fa-project-diagram"></i>
Projects Participated ({{ attendance_stats.unique_projects }})
</h3>
</div>
<div class="projects-body">
{% for project in attendance_stats.projects %}
<div class="project-item">
<div class="project-icon">
<i class="fas fa-folder"></i>
</div>
<div class="project-info">
<h4>{{ project.name }}</h4>
<p>
{% if project.description %}
{{ project.description[:100] }}{% if project.description|length > 100 %}...{% endif %}
{% else %}
No description available
{% endif %}
</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="projects-list">
<div class="projects-body">
<div class="no-projects">
<i class="fas fa-folder-open"></i>
<h3>No Projects Yet</h3>
<p>This employee hasn't participated in any projects yet.</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+374
View File
@@ -0,0 +1,374 @@
{% extends "base_authenticated.html" %}
{% set page_title = "Employee Management" %}
{% block title %}{{ page_title }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/employees.css') }}">
{% endblock %}
{% block content %}
<div class="employees-page">
<!-- Page Header -->
<div class="employees-header">
<div class="header-content">
<div class="header-navigation">
<a href="{{ url_for('dashboard') }}" class="back-button">
<i class="fas fa-arrow-left"></i>
Back to Dashboard
</a>
</div>
<h1><i class="fas fa-users"></i> Employee Management</h1>
<p class="header-description">
Manage employee records and view attendance statistics
</p>
</div>
<div class="header-actions">
{% if session.role == 'admin' %}
<a href="{{ url_for('create_employee') }}" class="btn btn-primary">
<i class="fas fa-plus"></i>
Add Employee
</a>
{% endif %}
</div>
</div>
<!-- Statistics Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<h3>{{ stats.total_employees }}</h3>
<p>Total Employees</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-id-badge"></i>
</div>
<div class="stat-info">
<h3>{{ stats.employees_with_title }}</h3>
<p>With Job Titles</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-briefcase"></i>
</div>
<div class="stat-info">
<h3>{{ stats.unique_titles }}</h3>
<p>Unique Job Titles</p>
</div>
</div>
{% if search %}
<div class="stat-card search-results">
<div class="stat-icon">
<i class="fas fa-search"></i>
</div>
<div class="stat-info">
<h3>{{ stats.search_results }}</h3>
<p>Search Results</p>
</div>
</div>
{% endif %}
</div>
<!-- Search and Filter Section -->
<div class="search-section">
<form method="GET" class="search-form">
<div class="search-input-group">
<input
type="text"
name="search"
value="{{ search }}"
placeholder="Search by name, employee ID, or job title..."
class="search-input"
autocomplete="off"
>
<button type="submit" class="search-btn">
<i class="fas fa-search"></i>
</button>
{% if search %}
<a href="{{ url_for('employees') }}" class="clear-search-btn" title="Clear search">
<i class="fas fa-times"></i>
</a>
{% endif %}
</div>
</form>
</div>
<!-- Employee Table -->
<div class="employees-table-container">
<div class="table-header">
<h2>
{% if search %}
Search Results for "{{ search }}"
{% else %}
All Employees
{% endif %}
</h2>
<div class="table-info">
Showing {{ employees.items|length }} of {{ employees.total }} employees
{% if employees.pages > 1 %}
(Page {{ employees.page }} of {{ employees.pages }})
{% endif %}
</div>
</div>
<div class="table-responsive">
<table class="employees-table">
<thead>
<tr>
<th>#</th>
<th>Employee ID</th>
<th>Name</th>
<th>Job Title</th>
<th>Contract ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for employee in employees.items %}
<tr class="employee-row" data-employee-id="{{ employee.id }}">
<td class="row-number">{{ loop.index + (employees.page - 1) * employees.per_page }}</td>
<td class="employee-id">
<span class="id-badge">{{ employee.id }}</span>
</td>
<td class="employee-name">
<div class="name-container">
<div class="avatar">
<i class="fas fa-user-circle"></i>
</div>
<div class="name-details">
<h4>{{ employee.full_name }}</h4>
<p>{{ employee.firstName }} {{ employee.lastName }}</p>
</div>
</div>
</td>
<td class="employee-title">
{% if employee.title %}
<span class="title-badge">{{ employee.title }}</span>
{% else %}
<span class="no-title">No Title</span>
{% endif %}
</td>
<td class="contract-id">
<span class="contract-badge">{{ employee.contractId }}</span>
</td>
<td class="actions">
<div class="action-buttons">
<a href="{{ url_for('employee_detail', employee_index=employee.index) }}"
class="btn btn-sm btn-info"
title="View Details">
<i class="fas fa-eye"></i>
</a>
{% if session.role == 'admin' %}
<a href="{{ url_for('edit_employee', employee_index=employee.index) }}"
class="btn btn-sm btn-warning"
title="Edit Employee">
<i class="fas fa-edit"></i>
</a>
<button class="btn btn-sm btn-danger delete-btn"
data-employee-index="{{ employee.index }}"
data-employee-name="{{ employee.full_name }}"
title="Delete Employee">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if employees.pages > 1 %}
<div class="pagination-container">
<nav class="pagination-nav">
<ul class="pagination">
<!-- Previous Page -->
{% if employees.has_prev %}
<li>
<a href="{{ url_for('employees', page=employees.prev_num, search=search) }}"
class="pagination-link">
<i class="fas fa-chevron-left"></i>
Previous
</a>
</li>
{% endif %}
<!-- Page Numbers -->
{% for page_num in employees.iter_pages() %}
{% if page_num %}
{% if page_num != employees.page %}
<li>
<a href="{{ url_for('employees', page=page_num, search=search) }}"
class="pagination-link">
{{ page_num }}
</a>
</li>
{% else %}
<li>
<span class="pagination-link current">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li>
<span class="pagination-link"></span>
</li>
{% endif %}
{% endfor %}
<!-- Next Page -->
{% if employees.has_next %}
<li>
<a href="{{ url_for('employees', page=employees.next_num, search=search) }}"
class="pagination-link">
Next
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
<!-- Empty State -->
{% if employees.total == 0 %}
<div class="empty-state">
<div class="empty-icon">
<i class="fas fa-users-slash"></i>
</div>
<h3>
{% if search %}
No employees found for "{{ search }}"
{% else %}
No employees found
{% endif %}
</h3>
<p>
{% if search %}
Try adjusting your search terms or <a href="{{ url_for('employees') }}">view all employees</a>.
{% else %}
Get started by <a href="{{ url_for('create_employee') }}">adding your first employee</a>.
{% endif %}
</p>
</div>
{% endif %}
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-exclamation-triangle"></i> Confirm Deletion</h3>
<button class="close-modal" data-modal="deleteModal">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete employee <strong id="deleteEmployeeName"></strong>?</p>
<p class="warning-text">
<i class="fas fa-warning"></i>
This action cannot be undone. The employee will be permanently removed from the system.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-modal="deleteModal">Cancel</button>
<form id="deleteForm" method="POST" style="display: inline;">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash"></i>
Delete Employee
</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Delete button functionality
const deleteButtons = document.querySelectorAll('.delete-btn');
const deleteModal = document.getElementById('deleteModal');
const deleteForm = document.getElementById('deleteForm');
const deleteEmployeeName = document.getElementById('deleteEmployeeName');
deleteButtons.forEach(button => {
button.addEventListener('click', function() {
const employeeIndex = this.getAttribute('data-employee-index');
const employeeName = this.getAttribute('data-employee-name');
deleteEmployeeName.textContent = employeeName;
deleteForm.action = `/employees/${employeeIndex}/delete`;
deleteModal.style.display = 'flex';
});
});
// Modal close functionality
document.querySelectorAll('[data-modal]').forEach(element => {
element.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal');
document.getElementById(modalId).style.display = 'none';
});
});
// Close modal when clicking outside
deleteModal.addEventListener('click', function(e) {
if (e.target === this) {
this.style.display = 'none';
}
});
// Search form auto-submit with debouncing
const searchInput = document.querySelector('.search-input');
let searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (this.value.length >= 3 || this.value.length === 0) {
this.form.submit();
}
}, 500);
});
// Highlight search terms
const searchTerm = "{{ search }}";
if (searchTerm) {
highlightSearchTerms(searchTerm);
}
function highlightSearchTerms(term) {
const elements = document.querySelectorAll('.employee-name h4, .employee-name p, .employee-title .title-badge, .employee-id .id-badge');
const regex = new RegExp(`(${term})`, 'gi');
elements.forEach(element => {
const text = element.textContent;
if (text.toLowerCase().includes(term.toLowerCase())) {
element.innerHTML = text.replace(regex, '<mark>$1</mark>');
}
});
}
});
</script>
{% endblock %}