added some function for choose date
This commit is contained in:
@@ -107,9 +107,40 @@ def list_projects():
|
|||||||
return projects
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/preview-time")
|
||||||
|
async def preview_time_from_csv(file: UploadFile = File(...)):
|
||||||
|
"""
|
||||||
|
Parse a file and return the extracted work time without submitting.
|
||||||
|
"""
|
||||||
|
content = await file.read()
|
||||||
|
filename = (file.filename or "").lower()
|
||||||
|
content_type = (file.content_type or "").lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if filename.endswith(".xlsx") or "spreadsheetml" in content_type:
|
||||||
|
rows = parse_xlsx(content)
|
||||||
|
else:
|
||||||
|
rows = parse_csv(content.decode('utf-8-sig'))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
|
||||||
|
|
||||||
|
work_time = extract_work_time(rows)
|
||||||
|
|
||||||
|
if not work_time:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No valid work time found in the uploaded file."
|
||||||
|
)
|
||||||
|
|
||||||
|
return [{"date": k, "hours": v} for k, v in sorted(work_time.items())]
|
||||||
|
|
||||||
|
|
||||||
@app.post("/report-time", response_model=TimeReportResponse)
|
@app.post("/report-time", response_model=TimeReportResponse)
|
||||||
async def report_time_from_csv(
|
async def report_time_from_csv(
|
||||||
project_name: str,
|
project_name: str,
|
||||||
|
excluded_dates: str = "",
|
||||||
file: UploadFile = File(...)
|
file: UploadFile = File(...)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -168,10 +199,14 @@ async def report_time_from_csv(
|
|||||||
|
|
||||||
work_time = extract_work_time(rows)
|
work_time = extract_work_time(rows)
|
||||||
|
|
||||||
|
if excluded_dates:
|
||||||
|
excluded_list = [d.strip() for d in excluded_dates.split(",")]
|
||||||
|
work_time = {k: v for k, v in work_time.items() if k not in excluded_list}
|
||||||
|
|
||||||
if not work_time:
|
if not work_time:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="No valid work time found in the uploaded CSV."
|
detail="No valid work time found in the uploaded CSV after filtering."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit events
|
# Submit events
|
||||||
|
|||||||
@@ -242,6 +242,62 @@ body::before {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table th, .preview-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table th {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table tr {
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table tbody tr.excluded {
|
||||||
|
opacity: 0.5;
|
||||||
|
background: rgba(239, 68, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table tbody tr.excluded td {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
/* File Preview */
|
/* File Preview */
|
||||||
.file-preview {
|
.file-preview {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
|
|||||||
+27
-2
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ZynkTime - Time Reporting</title>
|
<title>ZynkTime - Time Reporting</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=2">
|
<link rel="stylesheet" href="/static/css/style.css?v=3">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -66,6 +66,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Entries Preview Table -->
|
||||||
|
<div id="previewContainer" style="display: none; margin-top: 1.5rem;">
|
||||||
|
<h4>Preview Time Entries</h4>
|
||||||
|
<p class="text-sm text-muted mb-2">Click on a row or checkbox to exclude it from reporting.</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="preview-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="50"><input type="checkbox" id="selectAllPreview" checked></th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Hours</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="previewTableBody">
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" align="right"><strong>Total Selected:</strong></td>
|
||||||
|
<td><strong id="previewTotalHours">0.00</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Project Selection -->
|
<!-- Project Selection -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="projectSelect">Select Project</label>
|
<label for="projectSelect">Select Project</label>
|
||||||
@@ -155,6 +180,6 @@
|
|||||||
<p>Processing your request...</p>
|
<p>Processing your request...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/app.js?v=2"></script>
|
<script src="/static/js/app.js?v=3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+96
-2
@@ -2,6 +2,7 @@
|
|||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
let projects = {};
|
let projects = {};
|
||||||
let lastEvents = [];
|
let lastEvents = [];
|
||||||
|
let previewData = [];
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
const uploadZone = document.getElementById('uploadZone');
|
const uploadZone = document.getElementById('uploadZone');
|
||||||
@@ -203,18 +204,105 @@ function setFile(file) {
|
|||||||
fileSize.textContent = formatFileSize(file.size);
|
fileSize.textContent = formatFileSize(file.size);
|
||||||
|
|
||||||
updateSubmitButton();
|
updateSubmitButton();
|
||||||
|
generatePreview(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePreview(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/preview-time', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPreview(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Preview error:', error);
|
||||||
|
showToast('Failed to generate preview: ' + error.message, 'error');
|
||||||
|
document.getElementById('previewContainer').style.display = 'none';
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(data) {
|
||||||
|
previewData = data.map(item => ({...item, selected: true}));
|
||||||
|
const container = document.getElementById('previewContainer');
|
||||||
|
container.style.display = 'block';
|
||||||
|
|
||||||
|
updatePreviewTable();
|
||||||
|
|
||||||
|
document.getElementById('selectAllPreview').onchange = (e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
previewData.forEach(item => item.selected = checked);
|
||||||
|
updatePreviewTable();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure togglePreviewItem is available globally for inline onclick handlers
|
||||||
|
window.togglePreviewItem = function(index) {
|
||||||
|
previewData[index].selected = !previewData[index].selected;
|
||||||
|
|
||||||
|
// Update "select all" checkbox state
|
||||||
|
const allSelected = previewData.every(item => item.selected);
|
||||||
|
const noneSelected = previewData.every(item => !item.selected);
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAllPreview');
|
||||||
|
selectAllCheckbox.checked = allSelected;
|
||||||
|
selectAllCheckbox.indeterminate = !allSelected && !noneSelected;
|
||||||
|
|
||||||
|
updatePreviewTable();
|
||||||
|
};
|
||||||
|
|
||||||
|
function updatePreviewTable() {
|
||||||
|
const tbody = document.getElementById('previewTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
let totalHours = 0;
|
||||||
|
let selectedCount = 0;
|
||||||
|
|
||||||
|
previewData.forEach((item, index) => {
|
||||||
|
if (item.selected) {
|
||||||
|
totalHours += item.hours;
|
||||||
|
selectedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
if (!item.selected) tr.classList.add('excluded');
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><input type="checkbox" ${item.selected ? 'checked' : ''} onclick="event.stopPropagation(); togglePreviewItem(${index})"></td>
|
||||||
|
<td>${item.date}</td>
|
||||||
|
<td>${item.hours.toFixed(2)}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tr.onclick = () => togglePreviewItem(index);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('previewTotalHours').textContent = totalHours.toFixed(2);
|
||||||
|
updateSubmitButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile() {
|
function removeFile() {
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
|
previewData = [];
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
uploadZone.style.display = 'block';
|
uploadZone.style.display = 'block';
|
||||||
filePreview.style.display = 'none';
|
filePreview.style.display = 'none';
|
||||||
|
document.getElementById('previewContainer').style.display = 'none';
|
||||||
updateSubmitButton();
|
updateSubmitButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSubmitButton() {
|
function updateSubmitButton() {
|
||||||
submitBtn.disabled = !(selectedFile && projectSelect.value);
|
const hasSelectedDates = previewData.length === 0 || previewData.some(item => item.selected);
|
||||||
|
submitBtn.disabled = !(selectedFile && projectSelect.value && hasSelectedDates);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDefaultDateRange() {
|
function setDefaultDateRange() {
|
||||||
@@ -238,10 +326,16 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
const projectName = projectSelect.value;
|
const projectName = projectSelect.value;
|
||||||
|
|
||||||
|
const excludedDates = previewData
|
||||||
|
.filter(item => !item.selected)
|
||||||
|
.map(item => item.date)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/report-time?project_name=${encodeURIComponent(projectName)}`, {
|
const url = `/report-time?project_name=${encodeURIComponent(projectName)}&excluded_dates=${encodeURIComponent(excludedDates)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user