997 lines
30 KiB
HTML
997 lines
30 KiB
HTML
{% extends "base_authenticated.html" %}
|
|
{% block title %}Import Time Attendance Data - {{ COMPANY_NAME }}{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
|
|
<style>
|
|
.form-select:hover {
|
|
border-color: #8b5cf6;
|
|
}
|
|
|
|
.form-select:focus {
|
|
outline: none;
|
|
border-color: #8b5cf6;
|
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
|
}
|
|
|
|
.validation-preview {
|
|
background: #f8fafc;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 12px;
|
|
padding: 1.5rem;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.validation-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.validation-stat {
|
|
background: white;
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.validation-stat .number {
|
|
font-size: 1.75rem;
|
|
font-weight: 700;
|
|
color: #2d3748;
|
|
}
|
|
|
|
.validation-stat .label {
|
|
font-size: 0.875rem;
|
|
color: #718096;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.validation-stat.success .number { color: #48bb78; }
|
|
.validation-stat.warning .number { color: #ed8936; }
|
|
.validation-stat.error .number { color: #f56565; }
|
|
|
|
.import-options {
|
|
background: white;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.checkbox-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.checkbox-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.checkbox-item input[type="checkbox"] {
|
|
margin-top: 0.25rem;
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.checkbox-label {
|
|
flex: 1;
|
|
}
|
|
|
|
.checkbox-label strong {
|
|
display: block;
|
|
color: #2d3748;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.checkbox-label span {
|
|
color: #718096;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.progress-indicator {
|
|
display: none;
|
|
margin-top: 1.25rem;
|
|
padding: 1.25rem 1.5rem;
|
|
background: #ebf8ff;
|
|
border: 1px solid #90cdf4;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.progress-indicator.active {
|
|
display: block;
|
|
}
|
|
|
|
.progress-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
margin-bottom: 0.75rem;
|
|
font-weight: 600;
|
|
color: #2b6cb0;
|
|
}
|
|
|
|
.progress-bar-track {
|
|
background: #bee3f8;
|
|
border-radius: 999px;
|
|
height: 18px;
|
|
overflow: hidden;
|
|
margin-bottom: 0.5rem;
|
|
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.progress-bar-fill {
|
|
height: 100%;
|
|
width: 0%;
|
|
background: linear-gradient(90deg, #4299e1, #3182ce);
|
|
border-radius: 999px;
|
|
transition: width 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
padding-right: 6px;
|
|
}
|
|
|
|
.progress-bar-fill .pct-label {
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
color: white;
|
|
white-space: nowrap;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.progress-bar-fill.has-width .pct-label {
|
|
opacity: 1;
|
|
}
|
|
|
|
.progress-counters {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 0.8rem;
|
|
color: #4a5568;
|
|
}
|
|
|
|
.progress-message {
|
|
margin-top: 0.4rem;
|
|
font-size: 0.8rem;
|
|
color: #718096;
|
|
font-style: italic;
|
|
min-height: 1.1em;
|
|
}
|
|
|
|
.progress-done-message {
|
|
display: none;
|
|
margin-top: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.progress-done-message.success {
|
|
background: #f0fff4;
|
|
border: 1px solid #68d391;
|
|
color: #276749;
|
|
display: block;
|
|
}
|
|
|
|
.progress-done-message.error {
|
|
background: #fff5f5;
|
|
border: 1px solid #fc8181;
|
|
color: #c53030;
|
|
display: block;
|
|
}
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2.5px solid #bee3f8;
|
|
border-top-color: #4299e1;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Fixed styles for instructions section */
|
|
.instructions-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.instruction-item {
|
|
text-align: center;
|
|
padding: 1.5rem;
|
|
background: #f8fafc;
|
|
border-radius: 0.75rem;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.instruction-icon {
|
|
width: 60px;
|
|
height: 60px;
|
|
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: 1.5rem;
|
|
}
|
|
|
|
.instruction-item h3 {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.instruction-item p {
|
|
color: #64748b;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.sample-format {
|
|
background: #f8fafc;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 0.75rem;
|
|
padding: 1.5rem;
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.sample-format h4 {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
margin-bottom: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.format-table {
|
|
overflow-x: auto;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.format-table table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: white;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.format-table thead {
|
|
background: linear-gradient(135deg, #4299e1, #3182ce);
|
|
color: white;
|
|
}
|
|
|
|
.format-table th {
|
|
padding: 0.75rem 1rem;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
border-bottom: 2px solid #2b6cb0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.format-table td {
|
|
padding: 0.75rem 1rem;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
color: #4a5568;
|
|
}
|
|
|
|
.format-table tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.format-table tbody tr:hover {
|
|
background: #f7fafc;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="time-attendance-page">
|
|
<!-- Page Header -->
|
|
<div class="time-attendance-header">
|
|
<div class="header-navigation">
|
|
<a href="{{ url_for('time_attendance_dashboard') }}" class="back-button">
|
|
<i class="fas fa-arrow-left"></i>
|
|
Back to Time Attendance
|
|
</a>
|
|
</div>
|
|
|
|
<div class="header-content">
|
|
<h1>
|
|
<i class="fas fa-file-import"></i>
|
|
Import Time Attendance Data
|
|
</h1>
|
|
<p class="header-description">
|
|
Upload Excel files containing employee time attendance records with enhanced validation
|
|
</p>
|
|
</div>
|
|
|
|
<div class="header-actions">
|
|
<a href="{{ url_for('download_import_template') }}" class="btn btn-secondary">
|
|
<i class="fas fa-download"></i>
|
|
Download Template
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File Upload Form -->
|
|
<div class="import-section">
|
|
<div class="import-header">
|
|
<h2>
|
|
<i class="fas fa-upload"></i>
|
|
Upload Excel File
|
|
</h2>
|
|
</div>
|
|
<div class="import-body">
|
|
<form id="importForm" method="POST" enctype="multipart/form-data" action="{{ url_for('import_time_attendance') }}">
|
|
|
|
<!-- Project Selection - REQUIRED (moved to top) -->
|
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
|
<h3 style="margin-bottom: 0.75rem;">
|
|
<i class="fas fa-project-diagram"></i>
|
|
Select Project <span style="color: #ef4444;">*</span>
|
|
</h3>
|
|
<select name="project_id" id="project_id" class="form-select" required style="
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.9375rem;
|
|
background-color: #ffffff;
|
|
transition: all 0.2s ease;
|
|
">
|
|
<option value="">-- Select Project (Required) --</option>
|
|
{% for project in projects %}
|
|
<option value="{{ project.id }}">{{ project.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p style="
|
|
margin-top: 0.5rem;
|
|
font-size: 0.875rem;
|
|
color: #64748b;
|
|
">
|
|
<i class="fas fa-info-circle"></i>
|
|
<strong>Required:</strong> Select which project this import data belongs to for filtering and reporting
|
|
</p>
|
|
</div>
|
|
|
|
<!-- File Upload Area -->
|
|
<div class="file-upload-area" id="fileUploadArea">
|
|
<div class="upload-icon">
|
|
<i class="fas fa-cloud-upload-alt"></i>
|
|
</div>
|
|
<div class="upload-text">
|
|
<h3>Drag & Drop Excel Files</h3>
|
|
<p>Or click to browse and select Excel files (.xlsx, .xls). Multiple files supported.</p>
|
|
<div class="file-info" id="fileInfo" style="display: none;">
|
|
<i class="fas fa-file-excel"></i>
|
|
<span class="file-name"></span>
|
|
<span class="file-size"></span>
|
|
<span class="file-count"></span>
|
|
</div>
|
|
</div>
|
|
<input type="file"
|
|
id="fileInput"
|
|
name="files"
|
|
accept=".xlsx,.xls"
|
|
class="file-input"
|
|
multiple
|
|
required>
|
|
</div>
|
|
|
|
<!-- Import Options (hidden — defaults preserved in DOM for form submission) -->
|
|
<div class="import-options" style="display: none;" aria-hidden="true">
|
|
<div class="checkbox-group">
|
|
<div class="checkbox-item">
|
|
<input type="checkbox" id="analyze_duplicates" name="analyze_duplicates" value="true" checked>
|
|
</div>
|
|
<div class="checkbox-item">
|
|
<input type="checkbox" id="analyze_invalid" name="analyze_invalid" value="true" checked>
|
|
</div>
|
|
<div class="checkbox-item">
|
|
<input type="checkbox" id="skip_duplicates" name="skip_duplicates" value="true">
|
|
</div>
|
|
<div class="checkbox-item">
|
|
<input type="checkbox" id="validate_only" name="validate_only" value="true">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Import Source -->
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="import_source" class="form-label">
|
|
<i class="fas fa-tag"></i>
|
|
Import Source Description
|
|
</label>
|
|
<input type="text"
|
|
id="import_source"
|
|
name="import_source"
|
|
class="form-input"
|
|
placeholder="e.g., Monthly Attendance Report - October 2025"
|
|
value="Excel Import - {{ current_date }}">
|
|
<small class="form-help">Optional description for this import batch</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress Indicator -->
|
|
<div id="progressIndicator" class="progress-indicator">
|
|
<div class="progress-header">
|
|
<div class="spinner" id="progressSpinner"></div>
|
|
<span id="progressTitle">Uploading & preparing import...</span>
|
|
</div>
|
|
<div class="progress-bar-track">
|
|
<div class="progress-bar-fill" id="progressBarFill">
|
|
<span class="pct-label" id="progressPctLabel">0%</span>
|
|
</div>
|
|
</div>
|
|
<div class="progress-counters">
|
|
<span id="progressCounter">0 of 0 records processed</span>
|
|
<span id="progressPct">0%</span>
|
|
</div>
|
|
<div class="progress-message" id="progressMessage">Starting...</div>
|
|
<div class="progress-done-message" id="progressDoneMsg"></div>
|
|
</div>
|
|
|
|
<!-- Form Actions -->
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-outline" onclick="resetForm()">
|
|
<i class="fas fa-undo"></i>
|
|
Reset Form
|
|
</button>
|
|
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>
|
|
<i class="fas fa-upload"></i>
|
|
<span class="btn-text">Import Data</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Import Instructions -->
|
|
<div class="import-section">
|
|
<div class="import-header">
|
|
<h2>
|
|
<i class="fas fa-info-circle"></i>
|
|
Import Instructions
|
|
</h2>
|
|
</div>
|
|
<div class="import-body">
|
|
<div class="instructions-grid">
|
|
<div class="instruction-item">
|
|
<div class="instruction-icon">
|
|
<i class="fas fa-file-excel"></i>
|
|
</div>
|
|
<h3>Supported Formats</h3>
|
|
<p>Excel files (.xlsx, .xls) with time attendance data</p>
|
|
</div>
|
|
|
|
<div class="instruction-item">
|
|
<div class="instruction-icon">
|
|
<i class="fas fa-columns"></i>
|
|
</div>
|
|
<h3>Required Columns</h3>
|
|
<p>ID, Date, Time, Location Name, Action Description</p>
|
|
</div>
|
|
|
|
<div class="instruction-item">
|
|
<div class="instruction-icon">
|
|
<i class="fas fa-shield-alt"></i>
|
|
</div>
|
|
<h3>Smart Validation</h3>
|
|
<p>Automatic duplicate detection and data validation</p>
|
|
</div>
|
|
|
|
<div class="instruction-item">
|
|
<div class="instruction-icon">
|
|
<i class="fas fa-copy"></i>
|
|
</div>
|
|
<h3>Duplicate Handling</h3>
|
|
<p>Automatically skip duplicate records during import</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sample-format">
|
|
<h4>
|
|
<i class="fas fa-table"></i>
|
|
Expected Excel Format
|
|
</h4>
|
|
<div class="format-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Name</th>
|
|
<th>Platform</th>
|
|
<th>Date</th>
|
|
<th>Time</th>
|
|
<th>Location Name</th>
|
|
<th>Action Description</th>
|
|
<th>Event Description</th>
|
|
<th>Recorded Address</th>
|
|
<th>Distance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>12345</td>
|
|
<td>John Doe</td>
|
|
<td>iPhone - iOS</td>
|
|
<td>2025-10-06</td>
|
|
<td>09:00:00</td>
|
|
<td>HQ Suite 210</td>
|
|
<td>Check In</td>
|
|
<td>Main Office</td>
|
|
<td>123 Main St</td>
|
|
<td>0.125</td>
|
|
</tr>
|
|
<tr>
|
|
<td>67890</td>
|
|
<td>Jane Smith</td>
|
|
<td>Android</td>
|
|
<td>2025-10-06</td>
|
|
<td>17:30:00</td>
|
|
<td>HQ Suite 210</td>
|
|
<td>Check Out</td>
|
|
<td>Main Office</td>
|
|
<td>123 Main St</td>
|
|
<td>0.125</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Validation Results -->
|
|
{% if validation_result %}
|
|
<div class="import-results {% if validation_result.valid %}success{% else %}error{% endif %}">
|
|
<div class="results-header">
|
|
<h2>
|
|
{% if validation_result.valid %}
|
|
<i class="fas fa-check-circle text-success"></i>
|
|
File Validation Successful
|
|
{% else %}
|
|
<i class="fas fa-exclamation-circle text-danger"></i>
|
|
File Validation Failed
|
|
{% endif %}
|
|
</h2>
|
|
</div>
|
|
<div class="results-body">
|
|
<div class="validation-stats">
|
|
<div class="validation-stat">
|
|
<div class="number">{{ validation_result.total_rows }}</div>
|
|
<div class="label">Total Rows</div>
|
|
</div>
|
|
<div class="validation-stat success">
|
|
<div class="number">{{ validation_result.valid_rows }}</div>
|
|
<div class="label">Valid Rows</div>
|
|
</div>
|
|
{% if validation_result.invalid_rows > 0 %}
|
|
<div class="validation-stat warning">
|
|
<div class="number">{{ validation_result.invalid_rows }}</div>
|
|
<div class="label">Invalid Rows</div>
|
|
</div>
|
|
{% endif %}
|
|
<div class="validation-stat">
|
|
<div class="number">{{ validation_result.columns|length }}</div>
|
|
<div class="label">Columns Found</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if validation_result.file_info %}
|
|
<p><strong>File Size:</strong> {{ validation_result.file_info.size_mb }} MB</p>
|
|
{% endif %}
|
|
|
|
{% if validation_result.errors %}
|
|
<div class="error-list">
|
|
<h4>
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
Validation Errors
|
|
</h4>
|
|
<ul>
|
|
{% for error in validation_result.errors %}
|
|
<li>{{ error }}</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if validation_result.warnings %}
|
|
<div class="warning-list">
|
|
<h4>
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
Warnings
|
|
</h4>
|
|
<ul>
|
|
{% for warning in validation_result.warnings %}
|
|
<li>{{ warning }}</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if validation_result.sample_data %}
|
|
<div class="sample-data">
|
|
<h4>
|
|
<i class="fas fa-eye"></i>
|
|
Sample Data Preview (First 3 Rows)
|
|
</h4>
|
|
<div class="table-container">
|
|
<table class="preview-table">
|
|
<thead>
|
|
<tr>
|
|
{% for column in validation_result.columns %}
|
|
<th>{{ column }}</th>
|
|
{% endfor %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in validation_result.sample_data[:3] %}
|
|
<tr>
|
|
{% for column in validation_result.columns %}
|
|
<td>{{ row.get(column, '') }}</td>
|
|
{% endfor %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Import Results -->
|
|
{% if import_result %}
|
|
<div class="import-results {% if import_result.success %}success{% else %}error{% endif %}">
|
|
<div class="results-header">
|
|
<h2>
|
|
{% if import_result.success %}
|
|
<i class="fas fa-check-circle text-success"></i>
|
|
Import Completed
|
|
{% else %}
|
|
<i class="fas fa-exclamation-circle text-danger"></i>
|
|
Import Failed
|
|
{% endif %}
|
|
</h2>
|
|
</div>
|
|
<div class="results-body">
|
|
<div class="validation-stats">
|
|
<div class="validation-stat">
|
|
<div class="number">{{ import_result.total_records }}</div>
|
|
<div class="label">Total Records</div>
|
|
</div>
|
|
<div class="validation-stat success">
|
|
<div class="number">{{ import_result.imported_records }}</div>
|
|
<div class="label">Successfully Imported</div>
|
|
</div>
|
|
{% if import_result.duplicate_records > 0 %}
|
|
<div class="validation-stat warning">
|
|
<div class="number">{{ import_result.duplicate_records }}</div>
|
|
<div class="label">Duplicates Skipped</div>
|
|
</div>
|
|
{% endif %}
|
|
{% if import_result.failed_records > 0 %}
|
|
<div class="validation-stat error">
|
|
<div class="number">{{ import_result.failed_records }}</div>
|
|
<div class="label">Failed Records</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if import_result.success %}
|
|
<div class="success-actions">
|
|
<p>
|
|
<strong>Batch ID:</strong> <code>{{ import_result.batch_id }}</code>
|
|
</p>
|
|
<div class="action-buttons">
|
|
<a href="{{ url_for('time_attendance_records', import_batch=import_result.batch_id) }}"
|
|
class="btn btn-primary">
|
|
<i class="fas fa-eye"></i>
|
|
View Imported Records
|
|
</a>
|
|
<a href="{{ url_for('time_attendance_dashboard') }}" class="btn btn-secondary">
|
|
<i class="fas fa-tachometer-alt"></i>
|
|
Back to Dashboard
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if import_result.errors %}
|
|
<div class="error-list">
|
|
<h4>
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
Import Errors (Showing first 10)
|
|
</h4>
|
|
<ul>
|
|
{% for error in import_result.errors[:10] %}
|
|
<li>{{ error }}</li>
|
|
{% endfor %}
|
|
{% if import_result.errors|length > 10 %}
|
|
<li><em>...and {{ import_result.errors|length - 10 }} more errors</em></li>
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if import_result.warnings %}
|
|
<div class="warning-list">
|
|
<h4>
|
|
<i class="fas fa-info-circle"></i>
|
|
Import Warnings (Showing first 10)
|
|
</h4>
|
|
<ul>
|
|
{% for warning in import_result.warnings[:10] %}
|
|
<li>{{ warning }}</li>
|
|
{% endfor %}
|
|
{% if import_result.warnings|length > 10 %}
|
|
<li><em>...and {{ import_result.warnings|length - 10 }} more warnings</em></li>
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script>
|
|
// ─── DOM references ────────────────────────────────────────────────────────
|
|
const fileInput = document.getElementById('fileInput');
|
|
const fileUploadArea = document.getElementById('fileUploadArea');
|
|
const fileInfo = document.getElementById('fileInfo');
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
const importForm = document.getElementById('importForm');
|
|
const progressIndicator= document.getElementById('progressIndicator');
|
|
const progressBarFill = document.getElementById('progressBarFill');
|
|
const progressPctLabel = document.getElementById('progressPctLabel');
|
|
const progressCounter = document.getElementById('progressCounter');
|
|
const progressPct = document.getElementById('progressPct');
|
|
const progressMessage = document.getElementById('progressMessage');
|
|
const progressTitle = document.getElementById('progressTitle');
|
|
const progressDoneMsg = document.getElementById('progressDoneMsg');
|
|
|
|
// ─── Drag & drop ───────────────────────────────────────────────────────────
|
|
fileUploadArea.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
fileUploadArea.classList.add('dragover');
|
|
});
|
|
fileUploadArea.addEventListener('dragleave', () => {
|
|
fileUploadArea.classList.remove('dragover');
|
|
});
|
|
fileUploadArea.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
fileUploadArea.classList.remove('dragover');
|
|
const files = e.dataTransfer.files;
|
|
if (files.length > 0) { fileInput.files = files; handleFileSelect(); }
|
|
});
|
|
fileUploadArea.addEventListener('click', () => fileInput.click());
|
|
fileInput.addEventListener('change', handleFileSelect);
|
|
|
|
function handleFileSelect() {
|
|
const files = fileInput.files;
|
|
if (!files.length) return;
|
|
|
|
const invalid = Array.from(files).filter(
|
|
f => !f.name.toLowerCase().endsWith('.xlsx') && !f.name.toLowerCase().endsWith('.xls')
|
|
);
|
|
if (invalid.length) {
|
|
alert('Invalid file format:\n' + invalid.map(f => f.name).join('\n') +
|
|
'\n\nPlease select only Excel files (.xlsx or .xls)');
|
|
resetForm();
|
|
return;
|
|
}
|
|
|
|
const totalSize = Array.from(files).reduce((s, f) => s + f.size, 0);
|
|
if (files.length === 1) {
|
|
fileInfo.querySelector('.file-name').textContent = files[0].name;
|
|
fileInfo.querySelector('.file-size').textContent = (totalSize / 1024 / 1024).toFixed(2) + ' MB';
|
|
fileInfo.querySelector('.file-count').textContent = '';
|
|
} else {
|
|
fileInfo.querySelector('.file-name').textContent = files.length + ' files selected';
|
|
fileInfo.querySelector('.file-size').textContent = (totalSize / 1024 / 1024).toFixed(2) + ' MB total';
|
|
const names = Array.from(files).map(f => f.name).join(', ');
|
|
const countEl = fileInfo.querySelector('.file-count');
|
|
countEl.textContent = '(' + names + ')';
|
|
Object.assign(countEl.style, { fontSize: '0.75rem', color: '#718096', display: 'block', marginTop: '0.5rem' });
|
|
}
|
|
fileInfo.style.display = 'flex';
|
|
fileInfo.style.flexDirection = 'column';
|
|
submitBtn.disabled = false;
|
|
}
|
|
|
|
// ─── Progress bar helpers ──────────────────────────────────────────────────
|
|
function setProgress(current, total, message) {
|
|
const pct = total > 0 ? Math.min(100, Math.round(current / total * 100)) : 0;
|
|
progressBarFill.style.width = pct + '%';
|
|
if (pct > 8) {
|
|
progressBarFill.classList.add('has-width');
|
|
progressPctLabel.textContent = pct + '%';
|
|
}
|
|
progressPct.textContent = pct + '%';
|
|
progressCounter.textContent = current + ' of ' + total + ' records processed';
|
|
if (message) progressMessage.textContent = message;
|
|
}
|
|
|
|
function showDone(success, message, errors) {
|
|
document.getElementById('progressSpinner').style.display = 'none';
|
|
progressDoneMsg.className = 'progress-done-message ' + (success ? 'success' : 'error');
|
|
progressTitle.textContent = success ? 'Import complete!' : 'Import failed';
|
|
let html = '<strong>' + message + '</strong>';
|
|
if (errors && errors.length > 0) {
|
|
html += '<ul style="margin: 0.5rem 0 0 1.25rem; padding: 0; text-align: left;">';
|
|
errors.forEach(function(err) {
|
|
html += '<li style="margin-bottom: 0.25rem;">' + err + '</li>';
|
|
});
|
|
html += '</ul>';
|
|
}
|
|
progressDoneMsg.innerHTML = html;
|
|
}
|
|
|
|
// ─── Form submit → SSE import flow ────────────────────────────────────────
|
|
importForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault(); // Always intercept; we use fetch + SSE instead
|
|
|
|
if (!fileInput.files.length) {
|
|
alert('Please select at least one file to import.');
|
|
return;
|
|
}
|
|
const projectSelect = document.getElementById('project_id');
|
|
if (!projectSelect.value) {
|
|
alert('Please select a project. Project selection is required.');
|
|
projectSelect.focus();
|
|
projectSelect.style.borderColor = '#ef4444';
|
|
return;
|
|
}
|
|
|
|
// Show progress UI
|
|
progressIndicator.classList.add('active');
|
|
progressDoneMsg.className = 'progress-done-message'; // hide
|
|
progressDoneMsg.textContent = '';
|
|
setProgress(0, 0, 'Uploading file...');
|
|
progressTitle.textContent = 'Uploading & preparing import...';
|
|
document.getElementById('progressSpinner').style.display = '';
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Importing...';
|
|
|
|
// Build FormData from the existing form (includes all checkboxes, selects, etc.)
|
|
const formData = new FormData(importForm);
|
|
|
|
// ── Step 1: POST the file to /start → get job_id ──────────────────────
|
|
let jobId;
|
|
try {
|
|
const resp = await fetch('{{ url_for("start_import_job") }}', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.success) {
|
|
showDone(false, 'Upload failed: ' + (data.error || 'Unknown error'));
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
|
|
return;
|
|
}
|
|
jobId = data.job_id;
|
|
} catch (err) {
|
|
showDone(false, 'Network error during upload: ' + err.message);
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
|
|
return;
|
|
}
|
|
|
|
progressMessage.textContent = 'File received. Starting import...';
|
|
|
|
// ── Step 2: Open SSE stream for this job ──────────────────────────────
|
|
const streamUrl = '{{ url_for("stream_import_progress", job_id="__JOB_ID__") }}'.replace('__JOB_ID__', jobId);
|
|
const evtSource = new EventSource(streamUrl);
|
|
|
|
evtSource.onmessage = (event) => {
|
|
let msg;
|
|
try { msg = JSON.parse(event.data); } catch { return; }
|
|
|
|
if (msg.type === 'status') {
|
|
progressMessage.textContent = msg.message;
|
|
progressTitle.textContent = 'Importing records...';
|
|
|
|
} else if (msg.type === 'progress') {
|
|
setProgress(msg.current, msg.total, msg.message);
|
|
if (msg.percent >= 5) {
|
|
progressTitle.textContent = 'Importing records — ' + msg.percent + '% complete';
|
|
}
|
|
|
|
} else if (msg.type === 'done') {
|
|
evtSource.close();
|
|
const result = msg.result;
|
|
setProgress(result.total_records, result.total_records, 'Import finished.');
|
|
|
|
let summary = '✅ Imported ' + result.imported_records + ' of ' + result.total_records + ' records.';
|
|
if (result.duplicate_records > 0)
|
|
summary += ' Skipped ' + result.duplicate_records + ' duplicates.';
|
|
if (result.failed_records > 0)
|
|
summary += ' ⚠️ ' + result.failed_records + ' records failed.';
|
|
|
|
const errors = (!result.success && result.errors && result.errors.length > 0)
|
|
? result.errors
|
|
: [];
|
|
showDone(result.success, summary, errors);
|
|
submitBtn.innerHTML = '<i class="fas fa-check"></i> Done';
|
|
|
|
// Redirect to records view after a short delay
|
|
if (result.success && result.batch_id) {
|
|
setTimeout(() => {
|
|
window.location.href = '{{ url_for("time_attendance_records") }}?import_batch=' + result.batch_id;
|
|
}, 2500);
|
|
} else {
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
|
|
}
|
|
|
|
} else if (msg.type === 'error') {
|
|
evtSource.close();
|
|
showDone(false, '❌ Import error: ' + msg.message);
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
|
|
|
|
} else if (msg.type === 'heartbeat') {
|
|
// Keep-alive — no action needed
|
|
}
|
|
};
|
|
|
|
evtSource.onerror = () => {
|
|
evtSource.close();
|
|
// Only show error if import hasn't completed yet
|
|
if (!progressDoneMsg.classList.contains('success')) {
|
|
showDone(false, 'Connection lost. Please check the records page to verify import status.');
|
|
}
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
|
|
};
|
|
});
|
|
|
|
// ─── Misc ─────────────────────────────────────────────────────────────────
|
|
document.getElementById('project_id').addEventListener('change', function() {
|
|
if (this.value) this.style.borderColor = '#e2e8f0';
|
|
});
|
|
|
|
function resetForm() {
|
|
importForm.reset();
|
|
fileInfo.style.display = 'none';
|
|
submitBtn.disabled = true;
|
|
progressIndicator.classList.remove('active');
|
|
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
|
|
progressBarFill.style.width = '0%';
|
|
progressBarFill.classList.remove('has-width');
|
|
progressDoneMsg.className = 'progress-done-message';
|
|
progressMessage.textContent = '';
|
|
progressCounter.textContent = '0 of 0 records processed';
|
|
progressPct.textContent = '0%';
|
|
}
|
|
|
|
if (window.history.replaceState) {
|
|
window.history.replaceState(null, null, window.location.href);
|
|
}
|
|
</script>
|
|
{% endblock %} |