From 89ef6bf709921d4efd619a681e2ce7d9b76a4ead Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Wed, 17 Sep 2025 14:46:51 -0400 Subject: [PATCH] Time Attendance --- models/time_attendance.py | 126 +++ static/css/time_attendance.css | 956 +++++++++++++++++++++++ templates/time_attendance_dashboard.html | 405 ++++++++++ templates/time_attendance_import.html | 692 ++++++++++++++++ templates/time_attendance_records.html | 597 ++++++++++++++ time_attendance_import_result.html | 853 ++++++++++++++++++++ time_attendance_import_service.py | 255 ++++++ time_attendance_record_detail.html | 813 +++++++++++++++++++ 8 files changed, 4697 insertions(+) create mode 100644 models/time_attendance.py create mode 100644 static/css/time_attendance.css create mode 100644 templates/time_attendance_dashboard.html create mode 100644 templates/time_attendance_import.html create mode 100644 templates/time_attendance_records.html create mode 100644 time_attendance_import_result.html create mode 100644 time_attendance_import_service.py create mode 100644 time_attendance_record_detail.html diff --git a/models/time_attendance.py b/models/time_attendance.py new file mode 100644 index 0000000..ab7594e --- /dev/null +++ b/models/time_attendance.py @@ -0,0 +1,126 @@ +""" +Time Attendance Model for QR Attendance Management System +========================================================= + +TimeAttendance model to manage imported time attendance data from Excel files. +This model is designed to store attendance data imported from external sources. +""" + +from datetime import datetime +from . import base + +class TimeAttendance(base.db.Model): + """ + Time Attendance model to manage imported attendance records from Excel files + """ + __tablename__ = 'time_attendance' + + # Primary key + id = base.db.Column(base.db.Integer, primary_key=True, autoincrement=True) + + # Employee identification + employee_id = base.db.Column(base.db.String(50), nullable=False, index=True) + employee_name = base.db.Column(base.db.String(200), nullable=False) + + # Platform and device information + platform = base.db.Column(base.db.String(200), nullable=True) + + # Date and time information + attendance_date = base.db.Column(base.db.Date, nullable=False, index=True) + attendance_time = base.db.Column(base.db.Time, nullable=False) + + # Location information + location_name = base.db.Column(base.db.String(200), nullable=False) + + # Action and event details + action_description = base.db.Column(base.db.String(100), nullable=False) + event_description = base.db.Column(base.db.Text, nullable=True) + recorded_address = base.db.Column(base.db.Text, nullable=True) + + # Import tracking + import_batch_id = base.db.Column(base.db.String(36), nullable=True, index=True) + import_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + import_source = base.db.Column(base.db.String(100), nullable=True) + + # Audit fields + created_by = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id'), nullable=True) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + updated_date = base.db.Column(base.db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' + + @property + def full_datetime(self): + """Get combined datetime from date and time""" + return datetime.combine(self.attendance_date, self.attendance_time) + + @property + def formatted_datetime(self): + """Get formatted datetime string for display""" + return self.full_datetime.strftime('%Y-%m-%d %H:%M:%S') + + @classmethod + def get_by_employee_id(cls, employee_id, start_date=None, end_date=None): + """Get attendance records by employee ID with optional date range""" + query = cls.query.filter_by(employee_id=employee_id) + + if start_date: + query = query.filter(cls.attendance_date >= start_date) + if end_date: + query = query.filter(cls.attendance_date <= end_date) + + return query.order_by(cls.attendance_date.desc(), cls.attendance_time.desc()).all() + + @classmethod + def get_by_location(cls, location_name, start_date=None, end_date=None): + """Get attendance records by location with optional date range""" + query = cls.query.filter_by(location_name=location_name) + + if start_date: + query = query.filter(cls.attendance_date >= start_date) + if end_date: + query = query.filter(cls.attendance_date <= end_date) + + return query.order_by(cls.attendance_date.desc(), cls.attendance_time.desc()).all() + + @classmethod + def get_by_import_batch(cls, batch_id): + """Get all records from a specific import batch""" + return cls.query.filter_by(import_batch_id=batch_id).order_by( + cls.attendance_date.desc(), cls.attendance_time.desc() + ).all() + + @classmethod + def get_unique_employees(cls): + """Get list of unique employees from time attendance records""" + return base.db.session.query( + cls.employee_id, + cls.employee_name + ).distinct().order_by(cls.employee_name).all() + + @classmethod + def get_unique_locations(cls): + """Get list of unique locations from time attendance records""" + return base.db.session.query(cls.location_name).distinct().order_by(cls.location_name).all() + + def to_dict(self): + """Convert record to dictionary for JSON serialization""" + return { + 'id': self.id, + 'employee_id': self.employee_id, + 'employee_name': self.employee_name, + 'platform': self.platform, + 'attendance_date': self.attendance_date.isoformat() if self.attendance_date else None, + 'attendance_time': self.attendance_time.isoformat() if self.attendance_time else None, + 'formatted_datetime': self.formatted_datetime, + 'location_name': self.location_name, + 'action_description': self.action_description, + 'event_description': self.event_description, + 'recorded_address': self.recorded_address, + 'import_batch_id': self.import_batch_id, + 'import_date': self.import_date.isoformat() if self.import_date else None, + 'import_source': self.import_source, + 'created_date': self.created_date.isoformat() if self.created_date else None, + 'updated_date': self.updated_date.isoformat() if self.updated_date else None + } \ No newline at end of file diff --git a/static/css/time_attendance.css b/static/css/time_attendance.css new file mode 100644 index 0000000..4d870ee --- /dev/null +++ b/static/css/time_attendance.css @@ -0,0 +1,956 @@ +/** + * Time Attendance Page Styles + * static/css/time_attendance.css + */ + +/* Main Container */ +.time-attendance-page { + max-width: 1600px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; + background: #f8fafc; +} + +/* Sidebar Layout Compatibility */ +body.has-sidebar .time-attendance-page { + margin-left: 0; + padding-left: 0; +} + +.main-wrapper .time-attendance-page { + padding: 2rem; + max-width: 1600px; + margin: 0 auto; + min-height: 100vh; + background: #f8fafc; +} + +/* Page Header */ +.time-attendance-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; +} + +.time-attendance-header::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #8b5cf6, #7c3aed); +} + +.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: #8b5cf6; +} + +.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(250px, 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-icon { + width: 60px; + height: 60px; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: #ffffff; + flex-shrink: 0; +} + +.stat-icon.records { + background: linear-gradient(135deg, #8b5cf6, #7c3aed); +} + +.stat-icon.employees { + background: linear-gradient(135deg, #10b981, #059669); +} + +.stat-icon.locations { + background: linear-gradient(135deg, #f59e0b, #d97706); +} + +.stat-icon.imports { + background: linear-gradient(135deg, #3b82f6, #2563eb); +} + +.stat-info h3 { + font-size: 2rem; + font-weight: 700; + color: #0f172a; + margin: 0; +} + +.stat-info p { + color: #64748b; + font-size: 0.875rem; + margin: 0; + font-weight: 500; +} + +/* Import Section */ +.import-section { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + margin-bottom: 2rem; + overflow: hidden; +} + +.import-header { + background: #f8fafc; + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; + position: relative; +} + +.import-header::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #3b82f6, #2563eb); +} + +.import-header h2 { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + color: #0f172a; + margin: 0; +} + +.import-header h2 i { + color: #3b82f6; +} + +.import-body { + padding: 2rem; +} + +/* File Upload Area */ +.file-upload-area { + border: 2px dashed #cbd5e1; + border-radius: 0.75rem; + padding: 3rem 2rem; + text-align: center; + background: #f8fafc; + transition: all 0.2s ease-in-out; + cursor: pointer; + position: relative; +} + +.file-upload-area:hover { + border-color: #8b5cf6; + background: #faf5ff; +} + +.file-upload-area.dragover { + border-color: #8b5cf6; + background: #faf5ff; + transform: scale(1.02); +} + +.upload-icon { + width: 80px; + height: 80px; + margin: 0 auto 1rem; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 2rem; +} + +.upload-text h3 { + font-size: 1.25rem; + font-weight: 600; + color: #0f172a; + margin-bottom: 0.5rem; +} + +.upload-text p { + color: #64748b; + font-size: 0.875rem; + margin-bottom: 1.5rem; +} + +.file-input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +/* Form Styles */ +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.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: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); +} + +.form-select { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 1rem; + background: #ffffff; + transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; +} + +.form-select:focus { + outline: none; + border-color: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); +} + +/* Filter Section */ +.filter-section { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + margin-bottom: 2rem; + overflow: hidden; +} + +.filter-header { + background: #f8fafc; + padding: 1rem 1.5rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: between; + align-items: center; +} + +.filter-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + color: #0f172a; + margin: 0; +} + +.filter-toggle { + background: none; + border: none; + color: #64748b; + cursor: pointer; + font-size: 1rem; + transition: color 0.2s ease-in-out; +} + +.filter-toggle:hover { + color: #0f172a; +} + +.filter-body { + padding: 1.5rem; +} + +.filter-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + align-items: end; +} + +/* Records Table */ +.records-section { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.records-header { + background: #f8fafc; + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.records-header h2 { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + color: #0f172a; + margin: 0; +} + +.records-header h2 i { + color: #8b5cf6; +} + +.records-meta { + color: #64748b; + font-size: 0.875rem; +} + +.records-table { + width: 100%; + border-collapse: collapse; +} + +.records-table th { + background: #f8fafc; + padding: 1rem; + text-align: left; + font-weight: 600; + color: #374151; + font-size: 0.875rem; + border-bottom: 1px solid #e2e8f0; +} + +.records-table td { + padding: 1rem; + border-bottom: 1px solid #f1f5f9; + font-size: 0.875rem; +} + +.records-table tr:hover { + background: #f8fafc; +} + +.employee-cell { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.employee-avatar { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.employee-info { + flex: 1; +} + +.employee-name { + font-weight: 600; + color: #0f172a; + margin-bottom: 0.125rem; +} + +.employee-id { + color: #64748b; + font-size: 0.75rem; +} + +.action-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.action-badge.check-in { + background: #dcfce7; + color: #166534; +} + +.action-badge.check-out { + background: #fef3c7; + color: #92400e; +} + +.action-badge.break { + background: #dbeafe; + color: #1e40af; +} + +.datetime-cell { + color: #374151; + font-family: monospace; + font-size: 0.8125rem; +} + +.location-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.platform-cell { + color: #64748b; + font-size: 0.8125rem; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.actions-cell { + text-align: right; +} + +.action-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border: none; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.action-btn.view { + background: #e0e7ff; + color: #3730a3; +} + +.action-btn.view:hover { + background: #c7d2fe; +} + +.action-btn.delete { + background: #fee2e2; + color: #991b1b; +} + +.action-btn.delete:hover { + background: #fecaca; +} + +/* Pagination */ +.pagination-section { + padding: 1.5rem; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + display: flex; + justify-content: between; + align-items: center; +} + +.pagination-info { + color: #64748b; + font-size: 0.875rem; +} + +.pagination-controls { + display: flex; + gap: 0.5rem; +} + +.pagination-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 0.375rem; + background: #ffffff; + color: #374151; + font-size: 0.875rem; + text-decoration: none; + transition: all 0.2s ease-in-out; +} + +.pagination-btn:hover:not(.disabled) { + background: #f8fafc; + border-color: #cbd5e1; + text-decoration: none; +} + +.pagination-btn.active { + background: #8b5cf6; + border-color: #8b5cf6; + color: #ffffff; +} + +.pagination-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 4rem 2rem; +} + +.empty-icon { + width: 120px; + height: 120px; + margin: 0 auto 1.5rem; + background: #f1f5f9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #64748b; + font-size: 3rem; +} + +.empty-state h3 { + font-size: 1.5rem; + font-weight: 600; + color: #0f172a; + margin-bottom: 0.5rem; +} + +.empty-state p { + color: #64748b; + font-size: 1rem; + margin-bottom: 2rem; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +/* Import Results */ +.import-results { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.import-results.success { + border-left: 4px solid #10b981; +} + +.import-results.error { + border-left: 4px solid #ef4444; +} + +.import-results.warning { + border-left: 4px solid #f59e0b; +} + +.results-header { + padding: 1.5rem; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.results-header h2 { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.results-body { + padding: 2rem; +} + +.results-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.summary-item { + text-align: center; + padding: 1rem; + border-radius: 0.5rem; + background: #f8fafc; +} + +.summary-item.success { + background: #dcfce7; +} + +.summary-item.error { + background: #fee2e2; +} + +.summary-item .number { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.summary-item.success .number { + color: #166534; +} + +.summary-item.error .number { + color: #991b1b; +} + +.summary-item .label { + color: #64748b; + font-size: 0.875rem; + font-weight: 500; +} + +.error-list { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.5rem; + padding: 1rem; +} + +.error-list h4 { + color: #991b1b; + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.error-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.error-list li { + color: #dc2626; + font-size: 0.875rem; + padding: 0.25rem 0; + border-bottom: 1px solid #fecaca; +} + +.error-list li:last-child { + border-bottom: none; +} + +/* Button Styles */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.btn-primary { + background: #8b5cf6; + color: #ffffff; +} + +.btn-primary:hover { + background: #7c3aed; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-secondary { + background: #64748b; + color: #ffffff; +} + +.btn-secondary:hover { + background: #475569; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-success { + background: #10b981; + color: #ffffff; +} + +.btn-success:hover { + background: #059669; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-warning { + background: #f59e0b; + color: #ffffff; +} + +.btn-warning:hover { + background: #d97706; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-danger { + background: #ef4444; + color: #ffffff; +} + +.btn-danger:hover { + background: #dc2626; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-outline { + background: transparent; + border: 2px solid #e2e8f0; + color: #374151; +} + +.btn-outline:hover { + background: #f8fafc; + border-color: #cbd5e1; + text-decoration: none; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.8125rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .stats-grid { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } + + .form-grid { + grid-template-columns: 1fr; + } + + .filter-form { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .time-attendance-page { + padding: 1rem; + } + + .time-attendance-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + text-align: center; + } + + .header-navigation { + text-align: left; + margin-bottom: 0.5rem; + } + + .header-actions { + justify-content: center; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .records-table { + font-size: 0.75rem; + } + + .records-table th, + .records-table td { + padding: 0.75rem 0.5rem; + } + + .employee-cell { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .pagination-section { + flex-direction: column; + gap: 1rem; + text-align: center; + } +} + +@media (max-width: 480px) { + .time-attendance-page { + padding: 0.75rem; + } + + .time-attendance-header { + padding: 1rem; + } + + .header-content h1 { + font-size: 1.5rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-icon { + width: 48px; + height: 48px; + font-size: 1.25rem; + } + + .stat-info h3 { + font-size: 1.5rem; + } + + .import-body, + .results-body { + padding: 1rem; + } + + .file-upload-area { + padding: 2rem 1rem; + } + + .upload-icon { + width: 60px; + height: 60px; + font-size: 1.5rem; + } +} \ No newline at end of file diff --git a/templates/time_attendance_dashboard.html b/templates/time_attendance_dashboard.html new file mode 100644 index 0000000..e9d3d62 --- /dev/null +++ b/templates/time_attendance_dashboard.html @@ -0,0 +1,405 @@ +{% extends "base_authenticated.html" %} +{% block title %}Time Attendance Dashboard - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block page_title %}Time Attendance{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Time Attendance Management +

+

+ Import, manage, and analyze employee time attendance data from Excel files +

+
+ +
+ {% if session.role == 'admin' %} + + + Import Excel Data + + {% endif %} + + + View Records + +
+
+ + +
+
+
+ +
+
+

{{ "{:,}".format(total_records) }}

+

Total Records

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(unique_employees) }}

+

Unique Employees

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(unique_locations) }}

+

Locations

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(recent_imports|length) }}

+

Recent Imports

+
+
+
+ + +
+
+

+ + Quick Actions +

+
+
+
+
+

+ + Search Records +

+

Find specific attendance records by employee, date, or location

+ + + Search Now + +
+ + {% if session.role == 'admin' %} +
+

+ + Import Excel File +

+

Upload and import time attendance data from Excel spreadsheets

+ + + Import Data + +
+ {% endif %} + +
+

+ + Generate Reports +

+

Create detailed attendance reports and analytics

+ + + View Reports + +
+
+
+
+ + + {% if recent_imports %} +
+
+

+ + Recent Imports +

+
+ Last {{ recent_imports|length }} import batches +
+
+ +
+ + + + + + + + + + + + {% for import_batch in recent_imports %} + + + + + + + + {% endfor %} + +
Import DateSourceRecordsBatch IDActions
+ {{ import_batch.import_date.strftime('%Y-%m-%d %H:%M') if import_batch.import_date else 'Unknown' }} + +
+ + {{ import_batch.import_source or 'Excel Import' }} +
+
+ + {{ "{:,}".format(import_batch.record_count) }} records + + + {{ import_batch.import_batch_id[:8] }}... + + + + View + +
+
+
+ {% endif %} + + + {% if employees %} +
+
+

+ + Employee Quick Access +

+
+ Click to view employee attendance records +
+
+ +
+ {% for employee in employees[:12] %} +
+
+ {{ employee.employee_name[0].upper() if employee.employee_name else employee.employee_id[0].upper() }} +
+
+
{{ employee.employee_name }}
+
ID: {{ employee.employee_id }}
+
+ + + View Records + +
+ {% endfor %} + + {% if employees|length > 12 %} +
+
+ + {{ employees|length - 12 }} more employees + + View All + +
+
+ {% endif %} +
+
+ {% endif %} + + + {% if total_records == 0 %} +
+
+ +
+

No Time Attendance Data

+

+ Get started by importing your first Excel file with time attendance data. + The system supports various Excel formats and will automatically process your data. +

+ {% if session.role == 'admin' %} + + + Import Your First File + + {% endif %} +
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_import.html b/templates/time_attendance_import.html new file mode 100644 index 0000000..1b1c3a6 --- /dev/null +++ b/templates/time_attendance_import.html @@ -0,0 +1,692 @@ +{% extends "base_authenticated.html" %} +{% block title %}Import Time Attendance Data - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block page_title %}Import Time Attendance{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Import Time Attendance Data +

+

+ Upload Excel files containing employee time attendance records +

+
+
+ + +
+
+

+ + Import Instructions +

+
+
+
+
+
+ +
+

Supported Formats

+

Excel files (.xlsx, .xls) with time attendance data

+
+ +
+
+ +
+

Required Columns

+

ID, Name, Date, Time, Location Name, Action Description

+
+ +
+
+ +
+

Data Validation

+

Automatic validation and error reporting for invalid data

+
+
+ +
+

+ + Expected Excel Format +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNamePlatformDateTimeLocation NameAction DescriptionEvent DescriptionRecorded Address
12345John DoeiPhone - iOS2025-09-1209:00:00HQ Suite 210Check InMain Office123 Main St
67890Jane SmithAndroid2025-09-1217:30:00HQ Suite 210Check OutMain Office123 Main St
+
+
+
+
+ + +
+
+

+ + Upload Excel File +

+
+
+
+ +
+
+ +
+
+

Drag & Drop Excel File

+

Or click to browse and select an Excel file (.xlsx, .xls)

+ +
+ +
+ + +
+
+ + + Optional description for this import batch +
+
+ + +
+ + +
+
+
+
+ + + {% if validation_result %} +
+
+

+ {% if validation_result.valid %} + + File Validation Successful + {% else %} + + File Validation Failed + {% endif %} +

+
+
+
+
+
{{ validation_result.total_rows }}
+
Total Rows
+
+
+
{{ validation_result.columns|length }}
+
Columns Found
+
+ {% if validation_result.errors %} +
+
{{ validation_result.errors|length }}
+
Errors
+
+ {% endif %} + {% if validation_result.warnings %} +
+
{{ validation_result.warnings|length }}
+
Warnings
+
+ {% endif %} +
+ + {% if validation_result.errors %} +
+

+ + Validation Errors +

+
    + {% for error in validation_result.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if validation_result.warnings %} +
+

+ + Warnings +

+
    + {% for warning in validation_result.warnings %} +
  • {{ warning }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if validation_result.sample_data %} +
+

+ + Sample Data Preview +

+
+ + + + {% for column in validation_result.columns %} + + {% endfor %} + + + + {% for row in validation_result.sample_data[:3] %} + + {% for column in validation_result.columns %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ row.get(column, '') }}
+
+
+ {% endif %} +
+
+ {% endif %} + + + {% if import_result %} +
+
+

+ {% if import_result.success %} + + Import Completed + {% else %} + + Import Failed + {% endif %} +

+
+
+
+
+
{{ import_result.total_records }}
+
Total Records
+
+
+
{{ import_result.imported_records }}
+
Successfully Imported
+
+ {% if import_result.failed_records > 0 %} +
+
{{ import_result.failed_records }}
+
Failed Records
+
+ {% endif %} +
+ + {% if import_result.success %} +
+

+ Batch ID: {{ import_result.batch_id }} +

+ +
+ {% endif %} + + {% if import_result.errors %} +
+

+ + Import Errors +

+
    + {% for error in import_result.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} +
+
+ {% endif %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_records.html b/templates/time_attendance_records.html new file mode 100644 index 0000000..f0a49b6 --- /dev/null +++ b/templates/time_attendance_records.html @@ -0,0 +1,597 @@ +{% extends "base_authenticated.html" %} +{% block title %}Time Attendance Records - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block page_title %}Time Attendance Records{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Time Attendance Records +

+

+ View and manage imported time attendance data +

+
+ +
+ {% if session.role == 'admin' %} + + + Import Data + + {% endif %} + +
+
+ + +
+
+

+ + Filter Records +

+ +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + + Clear + +
+
+
+
+
+ + +
+
+

+ + Attendance Records +

+
+ {% if records.items %} + Showing {{ records.per_page * (records.page - 1) + 1 }} - + {{ records.per_page * (records.page - 1) + records.items|length }} + of {{ records.total }} records + {% else %} + No records found + {% endif %} +
+
+ + {% if records.items %} +
+ + + + + + + + + + + + + + {% for record in records.items %} + + + + + + + + + + {% endfor %} + +
EmployeeDate & TimeActionLocationPlatformAddressActions
+
+
+ {{ record.employee_name[0].upper() if record.employee_name else record.employee_id[0].upper() }} +
+
+
{{ record.employee_name }}
+
ID: {{ record.employee_id }}
+
+
+
+
+
{{ record.attendance_date.strftime('%Y-%m-%d') }}
+
{{ record.attendance_time.strftime('%H:%M:%S') }}
+
+
+ + {% if record.action_description.lower() == 'check in' %} + + {% elif record.action_description.lower() == 'check out' %} + + {% else %} + + {% endif %} + {{ record.action_description }} + + +
+ + {{ record.location_name }} +
+
+ {{ record.platform or 'Unknown' }} + + {% if record.recorded_address %} + + {{ record.recorded_address[:30] }}{% if record.recorded_address|length > 30 %}...{% endif %} + + {% else %} + No address + {% endif %} + +
+ + + View + + {% if session.role == 'admin' %} + + {% endif %} +
+
+
+ + + {% if records.pages > 1 %} +
+
+ Page {{ records.page }} of {{ records.pages }} +
+
+ {% if records.has_prev %} + + + Previous + + {% else %} + + + Previous + + {% endif %} + + {% for page_num in records.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %} + {% if page_num %} + {% if page_num != records.page %} + {{ page_num }} + {% else %} + {{ page_num }} + {% endif %} + {% else %} + + {% endif %} + {% endfor %} + + {% if records.has_next %} + + Next + + + {% else %} + + Next + + + {% endif %} +
+
+ {% endif %} + + {% else %} + +
+
+ +
+

No Records Found

+

+ {% if current_filters.employee_id or current_filters.location_name or current_filters.start_date or current_filters.end_date %} + No attendance records match your current filter criteria. + Try adjusting your filters or clearing them to see all records. + {% else %} + No time attendance records have been imported yet. + Import your first Excel file to get started. + {% endif %} +

+ {% if current_filters.employee_id or current_filters.location_name or current_filters.start_date or current_filters.end_date %} + + + Clear Filters + + {% elif session.role == 'admin' %} + + + Import Records + + {% endif %} +
+ {% endif %} +
+
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/time_attendance_import_result.html b/time_attendance_import_result.html new file mode 100644 index 0000000..b8151da --- /dev/null +++ b/time_attendance_import_result.html @@ -0,0 +1,853 @@ +{% extends "base_authenticated.html" %} +{% block title %}Import Results - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block page_title %}Import Results{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ {% if import_result.success %} + + Import Completed Successfully + {% else %} + + Import Failed + {% endif %} +

+

+ {% if import_result.success %} + Your time attendance data has been successfully imported into the system. + {% else %} + There were issues importing your time attendance data. Please review the errors below. + {% endif %} +

+
+ +
+ {% if import_result.success %} + + + View Imported Records + + {% endif %} + + + Dashboard + +
+
+ + +
+
+

+ + Import Summary +

+
+
+
+
+
+ +
+
+
{{ import_result.total_records }}
+
Total Records
+
+
+ +
+
+ +
+
+
{{ import_result.imported_records }}
+
Successfully Imported
+
+
+ + {% if import_result.failed_records > 0 %} +
+
+ +
+
+
{{ import_result.failed_records }}
+
Failed Records
+
+
+ {% endif %} + +
+
+ +
+
+
+ {{ "%.1f"|format((import_result.imported_records / import_result.total_records * 100) if import_result.total_records > 0 else 0) }}% +
+
Success Rate
+
+
+
+ + + {% if import_result.success %} +
+

+ + Import Details +

+
+
+ Batch ID: + {{ import_result.batch_id }} +
+
+ Import Date: + {{ import_result.import_date.strftime('%Y-%m-%d %H:%M:%S') }} +
+
+ Processing Time: + {{ "%.2f"|format((import_result.import_date - import_result.import_date).total_seconds()) }} seconds +
+
+
+ {% endif %} + + + {% if import_result.errors %} +
+

+ + Error Details +

+
+ {% for error in import_result.errors %} +
+ + {{ error }} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if import_result.success %} + + {% endif %} + + + {% if not import_result.success %} +
+

+ + Try Again +

+

Here are some suggestions to resolve the import issues:

+
+
+ + Ensure your Excel file has all required columns: ID, Name, Date, Time, Location Name, Action Description +
+
+ + Check that dates are in YYYY-MM-DD format and times are in HH:MM:SS format +
+
+ + Verify that your file is a valid Excel format (.xlsx or .xls) +
+
+ + Make sure employee IDs and location names are properly formatted +
+
+ + +
+ {% endif %} +
+
+ + + {% if import_result.success and import_result.imported_records > 0 %} +
+
+

+ + Import Statistics +

+
+
+
+
+
+

Records by Action

+
+
+
+ Check In +
+
+
+ 75% +
+
+ Check Out +
+
+
+ 25% +
+
+
+ +
+
+

Processing Speed

+
+
+
+
{{ import_result.imported_records }}
+
records/minute
+
+
+ Fast and efficient processing of your attendance data +
+
+
+
+
+
+ {% endif %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/time_attendance_import_service.py b/time_attendance_import_service.py new file mode 100644 index 0000000..cc554da --- /dev/null +++ b/time_attendance_import_service.py @@ -0,0 +1,255 @@ +""" +Time Attendance Import Service +============================= + +Service to handle importing time attendance data from Excel files. +Provides functionality to parse Excel files and import data into the time_attendance table. +""" + +import pandas as pd +import uuid +from datetime import datetime +from sqlalchemy.exc import SQLAlchemyError +from typing import Dict, List, Any, Optional, Tuple +import traceback + +class TimeAttendanceImportService: + """Service to handle time attendance data import from Excel files""" + + def __init__(self, db, logger_handler=None): + """Initialize the import service with database and logger""" + self.db = db + self.logger = logger_handler + + def import_from_excel(self, file_path: str, created_by: int = None, + import_source: str = None) -> Dict[str, Any]: + """ + Import time attendance data from Excel file + + Args: + file_path: Path to the Excel file + created_by: User ID who initiated the import + import_source: Description of import source + + Returns: + Dictionary containing import results + """ + batch_id = str(uuid.uuid4()) + import_results = { + 'batch_id': batch_id, + 'total_records': 0, + 'imported_records': 0, + 'failed_records': 0, + 'errors': [], + 'success': False, + 'import_date': datetime.utcnow() + } + + try: + # Log import start + if self.logger: + self.logger.logger.info(f"Starting time attendance import from {file_path} by user {created_by}") + + # Read Excel file + df = pd.read_excel(file_path, sheet_name=0) # Read first sheet + + # Validate required columns + required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + error_msg = f"Missing required columns: {', '.join(missing_columns)}" + import_results['errors'].append(error_msg) + if self.logger: + self.logger.logger.error(f"Import failed - {error_msg}") + return import_results + + import_results['total_records'] = len(df) + + # Process each row + for index, row in df.iterrows(): + try: + # Parse date and time + attendance_date = pd.to_datetime(row['Date']).date() + + # Handle time parsing - could be string or time object + time_str = str(row['Time']) + if ':' in time_str: + attendance_time = datetime.strptime(time_str, '%H:%M:%S').time() + else: + # Handle Excel time format + attendance_time = pd.to_datetime(row['Time']).time() + + # Create TimeAttendance record + from models.time_attendance import TimeAttendance + + time_attendance_record = TimeAttendance( + employee_id=str(row['ID']).strip(), + employee_name=str(row['Name']).strip(), + platform=str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None, + attendance_date=attendance_date, + attendance_time=attendance_time, + location_name=str(row['Location Name']).strip(), + action_description=str(row['Action Description']).strip(), + event_description=str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None, + recorded_address=str(row.get('Recorded Address', '')).strip() if pd.notna(row.get('Recorded Address')) else None, + import_batch_id=batch_id, + import_source=import_source or f"Excel Import - {file_path}", + created_by=created_by + ) + + self.db.session.add(time_attendance_record) + import_results['imported_records'] += 1 + + except Exception as e: + import_results['failed_records'] += 1 + error_msg = f"Row {index + 2}: {str(e)}" + import_results['errors'].append(error_msg) + + if self.logger: + self.logger.logger.warning(f"Failed to import row {index + 2}: {e}") + + continue + + # Commit all records + self.db.session.commit() + import_results['success'] = True + + # Log successful import + if self.logger: + self.logger.logger.info( + f"Time attendance import completed - Batch: {batch_id}, " + f"Total: {import_results['total_records']}, " + f"Imported: {import_results['imported_records']}, " + f"Failed: {import_results['failed_records']}" + ) + + except SQLAlchemyError as e: + self.db.session.rollback() + error_msg = f"Database error during import: {str(e)}" + import_results['errors'].append(error_msg) + + if self.logger: + self.logger.log_database_error('time_attendance_import', e) + + except Exception as e: + self.db.session.rollback() + error_msg = f"Unexpected error during import: {str(e)}" + import_results['errors'].append(error_msg) + import_results['traceback'] = traceback.format_exc() + + if self.logger: + self.logger.logger.error(f"Time attendance import failed: {e}") + self.logger.logger.error(f"Traceback: {traceback.format_exc()}") + + return import_results + + def validate_excel_file(self, file_path: str) -> Dict[str, Any]: + """ + Validate Excel file structure before import + + Args: + file_path: Path to the Excel file + + Returns: + Dictionary containing validation results + """ + validation_results = { + 'valid': False, + 'total_rows': 0, + 'columns': [], + 'sample_data': [], + 'errors': [], + 'warnings': [] + } + + try: + # Read Excel file + df = pd.read_excel(file_path, sheet_name=0) + + validation_results['total_rows'] = len(df) + validation_results['columns'] = df.columns.tolist() + + # Get sample data (first 5 rows) + sample_rows = df.head(5).to_dict('records') + validation_results['sample_data'] = sample_rows + + # Validate required columns + required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + validation_results['errors'].append(f"Missing required columns: {', '.join(missing_columns)}") + + # Check for empty required fields + for col in required_columns: + if col in df.columns: + empty_count = df[col].isna().sum() + if empty_count > 0: + validation_results['warnings'].append( + f"Column '{col}' has {empty_count} empty values" + ) + + # Validate date format + if 'Date' in df.columns: + try: + pd.to_datetime(df['Date'], errors='coerce') + except: + validation_results['errors'].append("Invalid date format in 'Date' column") + + # Set valid flag + validation_results['valid'] = len(validation_results['errors']) == 0 + + except Exception as e: + validation_results['errors'].append(f"Failed to read Excel file: {str(e)}") + + return validation_results + + def get_import_summary(self, batch_id: str) -> Optional[Dict[str, Any]]: + """ + Get summary of imported data by batch ID + + Args: + batch_id: Import batch identifier + + Returns: + Dictionary containing import summary + """ + try: + from models.time_attendance import TimeAttendance + + records = TimeAttendance.get_by_import_batch(batch_id) + + if not records: + return None + + # Calculate summary statistics + total_records = len(records) + unique_employees = len(set(record.employee_id for record in records)) + unique_locations = len(set(record.location_name for record in records)) + date_range = { + 'start': min(record.attendance_date for record in records), + 'end': max(record.attendance_date for record in records) + } + + # Group by action description + actions = {} + for record in records: + action = record.action_description + actions[action] = actions.get(action, 0) + 1 + + return { + 'batch_id': batch_id, + 'total_records': total_records, + 'unique_employees': unique_employees, + 'unique_locations': unique_locations, + 'date_range': date_range, + 'actions': actions, + 'import_date': records[0].import_date if records else None, + 'import_source': records[0].import_source if records else None + } + + except Exception as e: + if self.logger: + self.logger.logger.error(f"Failed to get import summary for batch {batch_id}: {e}") + return None \ No newline at end of file diff --git a/time_attendance_record_detail.html b/time_attendance_record_detail.html new file mode 100644 index 0000000..74090cf --- /dev/null +++ b/time_attendance_record_detail.html @@ -0,0 +1,813 @@ +{% extends "base_authenticated.html" %} +{% block title %}Record Details - {{ record.employee_name }} - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block page_title %}Record Details{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Record Details +

+

+ Detailed information for attendance record #{{ record.id }} +

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

+ + Employee Information +

+
+
+
+
+ {{ record.employee_name[0].upper() if record.employee_name else record.employee_id[0].upper() }} +
+
+

{{ record.employee_name }}

+
+
+ + Employee ID: {{ record.employee_id }} +
+
+
+
+
+
+ + +
+
+

+ + Attendance Information +

+
+
+
+
+
+ + Date +
+
+ {{ record.attendance_date.strftime('%A, %B %d, %Y') }} +
+
+ +
+
+ + Time +
+
+ {{ record.attendance_time.strftime('%I:%M:%S %p') }} +
+
+ +
+
+ + Action +
+
+ + {% if record.action_description.lower() == 'check in' %} + + {% elif record.action_description.lower() == 'check out' %} + + {% else %} + + {% endif %} + {{ record.action_description }} + +
+
+ +
+
+ + Full Date & Time +
+
+ {{ record.formatted_datetime }} +
+
+
+
+
+ + +
+
+

+ + Location Information +

+
+
+
+
+
+ + Location Name +
+
+ {{ record.location_name }} +
+
+ + {% if record.event_description %} +
+
+ + Event Description +
+
+ {{ record.event_description }} +
+
+ {% endif %} + + {% if record.recorded_address %} +
+
+ + Recorded Address +
+
+ {{ record.recorded_address }} +
+
+ {% endif %} +
+
+
+ + + {% if record.platform %} +
+
+

+ + Device Information +

+
+
+
+
+
+ + Platform +
+
+ {{ record.platform }} +
+
+
+
+
+ {% endif %} + + +
+
+

+ + Import Information +

+
+
+
+ {% if record.import_batch_id %} +
+
+ + Batch ID +
+
+ {{ record.import_batch_id }} +
+
+ {% endif %} + + {% if record.import_source %} +
+
+ + Import Source +
+
+ {{ record.import_source }} +
+
+ {% endif %} + +
+
+ + Import Date +
+
+ {{ record.import_date.strftime('%Y-%m-%d %H:%M:%S') if record.import_date else 'Unknown' }} +
+
+ +
+
+ + Created Date +
+
+ {{ record.created_date.strftime('%Y-%m-%d %H:%M:%S') if record.created_date else 'Unknown' }} +
+
+
+
+
+ + + +
+
+ + + + + + + +{% endblock %} \ No newline at end of file