From 5d1b6abaae7137a1f4670a762930c1876ef18c2f Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Wed, 27 Aug 2025 12:46:32 -0400 Subject: [PATCH] Integrated employee management --- app.py | 337 +++++++++++++- models/__init__.py | 3 +- models/employee.py | 72 +++ static/css/employees.css | 712 ++++++++++++++++++++++++++++++ templates/base_authenticated.html | 6 +- templates/create_employee.html | 324 ++++++++++++++ templates/edit_employee.html | 374 ++++++++++++++++ templates/employee_detail.html | 521 ++++++++++++++++++++++ templates/employees.html | 374 ++++++++++++++++ 9 files changed, 2718 insertions(+), 5 deletions(-) create mode 100644 models/employee.py create mode 100644 static/css/employees.css create mode 100644 templates/create_employee.html create mode 100644 templates/edit_employee.html create mode 100644 templates/employee_detail.html create mode 100644 templates/employees.html diff --git a/app.py b/app.py index c552a86..9aa1fe1 100644 --- a/app.py +++ b/app.py @@ -195,7 +195,7 @@ STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager'] # Import and initialize models 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 logger_handler = AppLogger(app, db) @@ -1597,7 +1597,7 @@ def dashboard(): flash('Error loading dashboard. Please try again.', 'error') return redirect(url_for('login')) -# User management routes +# USER MANAGEMENT ROUTES @app.route('/profile', methods=['GET', 'POST']) @login_required @log_user_activity('profile_update') @@ -3012,7 +3012,7 @@ def api_active_projects(): 'error': 'Failed to fetch projects' }), 500 -# QR code management routes +# QR CODE MANAGEMENT ROUTES @app.route('/qr-codes/create', methods=['GET', 'POST']) @login_required @log_database_operations('qr_code_creation') @@ -5679,6 +5679,337 @@ def export_statistics(): flash('Error loading statistics. Please try again.', 'error') 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//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//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/') +@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 @app.template_filter('days_since') def days_since_filter(date): diff --git a/models/__init__.py b/models/__init__.py index e8688d2..c33d164 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -17,5 +17,6 @@ def set_db(database): from .qrcode import QRCode, QRCodeStyle from .project import Project from .attendance import AttendanceData + from .employee import Employee - return User, QRCode, QRCodeStyle, Project, AttendanceData \ No newline at end of file + return User, QRCode, QRCodeStyle, Project, AttendanceData, Employee \ No newline at end of file diff --git a/models/employee.py b/models/employee.py new file mode 100644 index 0000000..4df72d0 --- /dev/null +++ b/models/employee.py @@ -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'' + + @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 + } \ No newline at end of file diff --git a/static/css/employees.css b/static/css/employees.css new file mode 100644 index 0000000..017af86 --- /dev/null +++ b/static/css/employees.css @@ -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; + } +} \ No newline at end of file diff --git a/templates/base_authenticated.html b/templates/base_authenticated.html index 75aff14..624bb3e 100644 --- a/templates/base_authenticated.html +++ b/templates/base_authenticated.html @@ -61,9 +61,13 @@ Projects - + Users + + + Employees + Reports diff --git a/templates/create_employee.html b/templates/create_employee.html new file mode 100644 index 0000000..ddefc3c --- /dev/null +++ b/templates/create_employee.html @@ -0,0 +1,324 @@ +{% extends "base_authenticated.html" %} +{% set page_title = "Add New Employee" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ +
+
+ + +

Add New Employee

+

+ Create a new employee record in the system +

+
+
+ + +
+
+

+ + Employee Information +

+
+ +
+
+ +
+
+ + +
+ Must be a unique numeric identifier +
+
+ +
+ + +
+ Default is 1 if not specified +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ Optional field, maximum 20 characters +
+
+ + +
+ + + Cancel + + +
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/edit_employee.html b/templates/edit_employee.html new file mode 100644 index 0000000..685a9de --- /dev/null +++ b/templates/edit_employee.html @@ -0,0 +1,374 @@ +{% extends "base_authenticated.html" %} +{% set page_title = "Employee Management" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+ + +

Employee Management

+

+ Manage employee records and view attendance statistics +

+
+ +
+ {% if session.user_role == 'admin' %} + + + Add Employee + + {% endif %} +
+
+ + +
+
+
+ +
+
+

{{ stats.total_employees }}

+

Total Employees

+
+
+ +
+
+ +
+
+

{{ stats.employees_with_title }}

+

With Job Titles

+
+
+ +
+
+ +
+
+

{{ stats.unique_titles }}

+

Unique Job Titles

+
+
+ + {% if search %} +
+
+ +
+
+

{{ stats.search_results }}

+

Search Results

+
+
+ {% endif %} +
+ + +
+
+
+ + + {% if search %} + + + + {% endif %} +
+
+
+ + +
+
+

+ {% if search %} + Search Results for "{{ search }}" + {% else %} + All Employees + {% endif %} +

+
+ Showing {{ employees.items|length }} of {{ employees.total }} employees + {% if employees.pages > 1 %} + (Page {{ employees.page }} of {{ employees.pages }}) + {% endif %} +
+
+ +
+ + + + + + + + + + + + + {% for employee in employees.items %} + + + + + + + + + + + + + + {% endfor %} + +
#Employee IDNameJob TitleContract IDActions
{{ loop.index + (employees.page - 1) * employees.per_page }} + {{ employee.id }} + +
+
+ +
+
+

{{ employee.full_name }}

+

{{ employee.firstName }} {{ employee.lastName }}

+
+
+
+ {% if employee.title %} + {{ employee.title }} + {% else %} + No Title + {% endif %} + + {{ employee.contractId }} + +
+ + + + + {% if session.user_role == 'admin' %} + + + + + + {% endif %} +
+
+
+ + + {% if employees.pages > 1 %} +
+ +
+ {% endif %} + + + {% if employees.total == 0 %} +
+
+ +
+

+ {% if search %} + No employees found for "{{ search }}" + {% else %} + No employees found + {% endif %} +

+

+ {% if search %} + Try adjusting your search terms or view all employees. + {% else %} + Get started by adding your first employee. + {% endif %} +

+
+ {% endif %} +
+
+ + + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/employee_detail.html b/templates/employee_detail.html new file mode 100644 index 0000000..aeffa41 --- /dev/null +++ b/templates/employee_detail.html @@ -0,0 +1,521 @@ +{% extends "base_authenticated.html" %} +{% set page_title = "Employee Details - " + employee.full_name %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
+ +
+
+

{{ employee.full_name }}

+
+
+ + Employee ID: {{ employee.id }} +
+
+ + {{ employee.display_title }} +
+
+ + Contract ID: {{ employee.contractId }} +
+ {% if attendance_stats.latest_attendance %} +
+ + Last seen: {{ attendance_stats.latest_attendance.check_in_date.strftime('%B %d, %Y') }} +
+ {% endif %} +
+
+
+ + {% if session.role == 'admin' %} + + {% endif %} +
+ +
+
+
+

+ + Personal Information +

+
+

First Name: {{ employee.firstName }}

+

Last Name: {{ employee.lastName }}

+

Full Name: {{ employee.full_name }}

+
+
+ +
+

+ + Employment Details +

+
+

Employee ID: {{ employee.id }}

+

Job Title: {{ employee.display_title }}

+

Contract ID: {{ employee.contractId }}

+
+
+ +
+

+ + System Information +

+
+

Database Index: {{ employee.index }}

+

Record Status: Active

+

Attendance Records: {{ attendance_stats.total_attendance }}

+
+
+
+
+
+ + +
+
+

+ + Attendance Statistics +

+
+ +
+
+
+ +
+
+

{{ attendance_stats.total_attendance }}

+

Total Attendance

+
+
+ +
+
+ +
+
+

{{ attendance_stats.recent_attendance }}

+

Last 30 Days

+
+
+ +
+
+ +
+
+

{{ attendance_stats.unique_projects }}

+

Unique Projects

+
+
+ + {% if attendance_stats.latest_attendance %} +
+
+ +
+
+

{{ attendance_stats.latest_attendance.check_in_date.strftime('%m/%d') }}

+

Latest Check-in

+
+
+ {% endif %} +
+ + + {% if attendance_stats.projects %} +
+
+

+ + Projects Participated ({{ attendance_stats.unique_projects }}) +

+
+
+ {% for project in attendance_stats.projects %} +
+
+ +
+
+

{{ project.name }}

+

+ {% if project.description %} + {{ project.description[:100] }}{% if project.description|length > 100 %}...{% endif %} + {% else %} + No description available + {% endif %} +

+
+
+ {% endfor %} +
+
+ {% else %} +
+
+
+ +

No Projects Yet

+

This employee hasn't participated in any projects yet.

+
+
+
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/employees.html b/templates/employees.html new file mode 100644 index 0000000..47fac99 --- /dev/null +++ b/templates/employees.html @@ -0,0 +1,374 @@ +{% extends "base_authenticated.html" %} +{% set page_title = "Employee Management" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+ + +

Employee Management

+

+ Manage employee records and view attendance statistics +

+
+ +
+ {% if session.role == 'admin' %} + + + Add Employee + + {% endif %} +
+
+ + +
+
+
+ +
+
+

{{ stats.total_employees }}

+

Total Employees

+
+
+ +
+
+ +
+
+

{{ stats.employees_with_title }}

+

With Job Titles

+
+
+ +
+
+ +
+
+

{{ stats.unique_titles }}

+

Unique Job Titles

+
+
+ + {% if search %} +
+
+ +
+
+

{{ stats.search_results }}

+

Search Results

+
+
+ {% endif %} +
+ + +
+
+
+ + + {% if search %} + + + + {% endif %} +
+
+
+ + +
+
+

+ {% if search %} + Search Results for "{{ search }}" + {% else %} + All Employees + {% endif %} +

+
+ Showing {{ employees.items|length }} of {{ employees.total }} employees + {% if employees.pages > 1 %} + (Page {{ employees.page }} of {{ employees.pages }}) + {% endif %} +
+
+ +
+ + + + + + + + + + + + + {% for employee in employees.items %} + + + + + + + + + + + + + + {% endfor %} + +
#Employee IDNameJob TitleContract IDActions
{{ loop.index + (employees.page - 1) * employees.per_page }} + {{ employee.id }} + +
+
+ +
+
+

{{ employee.full_name }}

+

{{ employee.firstName }} {{ employee.lastName }}

+
+
+
+ {% if employee.title %} + {{ employee.title }} + {% else %} + No Title + {% endif %} + + {{ employee.contractId }} + +
+ + + + + {% if session.role == 'admin' %} + + + + + + {% endif %} +
+
+
+ + + {% if employees.pages > 1 %} +
+ +
+ {% endif %} + + + {% if employees.total == 0 %} +
+
+ +
+

+ {% if search %} + No employees found for "{{ search }}" + {% else %} + No employees found + {% endif %} +

+

+ {% if search %} + Try adjusting your search terms or view all employees. + {% else %} + Get started by adding your first employee. + {% endif %} +

+
+ {% endif %} +
+
+ + + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file