|
|
|
|
|
""" |
|
|
Run: |
|
|
uvicorn app:app --reload --port 8000 |
|
|
|
|
|
This app reads data.csv (expected columns below), computes metrics and serves: |
|
|
- / : HTML dashboard (JS + Chart.js) |
|
|
- /api/tasks : JSON data for charts |
|
|
- /report/pdf : PDF export |
|
|
- /report/excel : Excel export |
|
|
|
|
|
Expected CSV columns (case sensitive): |
|
|
Task ID,Task Name,Client,Phase,Value,Start Time,End Time,Duration (mins) |
|
|
|
|
|
If Duration (mins) is missing, the app will compute it using Start Time and End Time. |
|
|
Date format is flexible (pandas.parse). |
|
|
""" |
|
|
|
|
|
import io |
|
|
import math |
|
|
from fastapi import FastAPI, Query |
|
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse |
|
|
from reportlab.lib.pagesizes import A4 |
|
|
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer |
|
|
from reportlab.lib import colors |
|
|
from reportlab.lib.styles import getSampleStyleSheet |
|
|
from openpyxl import Workbook |
|
|
from datetime import datetime |
|
|
import pandas as pd |
|
|
import uvicorn |
|
|
|
|
|
app = FastAPI(title="Task Phase Dashboard (CSV)") |
|
|
|
|
|
CSV_PATH = "data.csv" |
|
|
|
|
|
|
|
|
HTML_TEMPLATE = """<!doctype html> |
|
|
<html lang='en'> |
|
|
<head> |
|
|
<meta charset='utf-8'> |
|
|
<title>Task Phase Dashboard</title> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
|
<script src='https://cdn.tailwindcss.com'></script> |
|
|
<script src='https://cdn.jsdelivr.net/npm/chart.js'></script> |
|
|
</head> |
|
|
<body class='bg-gray-50 text-left font-sans p-6'> |
|
|
<div class='max-w-7xl mx-auto bg-white shadow-lg rounded-2xl p-6'> |
|
|
<h2 class='text-2xl font-bold text-sky-600 mb-4 text-center'> Task Phase Dashboard (TASH)</h2> |
|
|
<div class='flex flex-wrap gap-4 items-center justify-between mb-4'> |
|
|
<div class='flex gap-2 items-center'> |
|
|
<label class='text-sm'>Client:</label> |
|
|
<select id='filterClient' onchange='load()' class='px-2 py-1 border rounded'></select> |
|
|
<label class='text-sm'>Phase:</label> |
|
|
<select id='filterPhase' onchange='load()' class='px-2 py-1 border rounded'></select> |
|
|
<label class='text-sm'>Task Name:</label> |
|
|
<select id='filterTask' onchange='load()' class='px-2 py-1 border rounded'></select> |
|
|
<label class='text-sm'>Value:</label> |
|
|
<select id='filterValue' onchange='load()' class='px-2 py-1 border rounded'></select> |
|
|
<label class='text-sm'>Delayed:</label> |
|
|
<select id='filterDelayed' onchange='load()' class='px-2 py-1 border rounded'> |
|
|
<option value=''>All</option> |
|
|
<option value='yes'>Yes</option> |
|
|
<option value='no'>No</option> |
|
|
</select> |
|
|
<button onclick='resetFilters()' class='ml-2 bg-gray-200 px-3 py-1 rounded'>Reset</button> |
|
|
</div> |
|
|
<div class='space-x-2'> |
|
|
<a href='/report/pdf' target='_blank' class='bg-green-500 hover:bg-green-600 text-white rounded-lg px-4 py-2 transition'>📄 Export PDF</a> |
|
|
<a href='/report/excel' target='_blank' class='bg-yellow-500 hover:bg-yellow-600 text-white rounded-lg px-4 py-2 transition'>📊 Export Excel</a> |
|
|
</div> |
|
|
</div> |
|
|
<div class='grid grid-cols-1 md:grid-cols-3 gap-4 mb-6'> |
|
|
<div class='p-4 bg-gray-100 rounded'> |
|
|
<div class='text-sm text-gray-600'>Total tasks</div> |
|
|
<div id='totalCount' class='text-2xl font-bold'>—</div> |
|
|
</div> |
|
|
<div class='p-4 bg-gray-100 rounded'> |
|
|
<div class='text-sm text-gray-600'>Completed</div> |
|
|
<div id='doneCount' class='text-2xl font-bold'>—</div> |
|
|
</div> |
|
|
<div class='p-4 bg-gray-100 rounded'> |
|
|
<div class='text-sm text-gray-600'>Avg Duration (mins)</div> |
|
|
<div id='avgDuration' class='text-2xl font-bold'>—</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class='grid grid-cols-1 lg:grid-cols-2 gap-6'> |
|
|
<div class='p-4 bg-white rounded shadow'> |
|
|
<h3 class='font-semibold mb-2'>Tasks per Client</h3> |
|
|
<canvas id='clientsBar' height='250'></canvas> |
|
|
</div> |
|
|
<div class='p-4 bg-white rounded shadow'> |
|
|
<h3 class='font-semibold mb-2'>Avg Duration per Client (mins)</h3> |
|
|
<canvas id='avgBar' height='250'></canvas> |
|
|
</div> |
|
|
<div class='p-4 bg-white rounded shadow'> |
|
|
<h3 class='font-semibold mb-2'>Avg Duration per Phase (mins)</h3> |
|
|
<canvas id='phaseAvgBar' height='250'></canvas> |
|
|
</div> |
|
|
<div class='p-4 bg-white rounded shadow'> |
|
|
<h3 class='font-semibold mb-2'>Avg Duration per Task (mins)</h3> |
|
|
<canvas id='taskAvgBar' height='250'></canvas> |
|
|
</div> |
|
|
<div class='p-4 bg-white rounded shadow'> |
|
|
<h3 class='font-semibold mb-2'>Value Distribution</h3> |
|
|
<canvas id='valuePie' height='250'></canvas> |
|
|
</div> |
|
|
<div class='p-4 bg-white rounded shadow'> |
|
|
<h3 class='font-semibold mb-2'>Tasks Over Time (by start date)</h3> |
|
|
<canvas id='timeline' height='250'></canvas> |
|
|
</div> |
|
|
<div class='p-4 bg-white rounded shadow col-span-1 lg:col-span-2'> |
|
|
<h3 class='font-semibold mb-2'>Task List</h3> |
|
|
<div class='overflow-x-auto'> |
|
|
<table class='min-w-full border text-sm'> |
|
|
<thead class='bg-slate-100'> |
|
|
<tr> |
|
|
<th class='p-2'>Task Name</th> |
|
|
<th class='p-2'>Client</th> |
|
|
<th class='p-2'>Phase</th> |
|
|
<th class='p-2'>Creative Stage</th> |
|
|
<th class='p-2'>Value</th> |
|
|
<th class='p-2'>Duration (mins)</th> |
|
|
<th class='p-2'>Start</th> |
|
|
<th class='p-2'>End</th> |
|
|
<th class='p-2'>Delayed</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id='taskTable'></tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<script> |
|
|
async function load(){ |
|
|
const client = document.getElementById('filterClient').value || ''; |
|
|
const phase = document.getElementById('filterPhase').value || ''; |
|
|
const task = document.getElementById('filterTask').value || ''; |
|
|
const value = document.getElementById('filterValue').value || ''; |
|
|
const delayed = document.getElementById('filterDelayed').value || ''; |
|
|
const params = new URLSearchParams(); |
|
|
if(client) params.set('client', client); |
|
|
if(phase) params.set('phase', phase); |
|
|
if(task) params.set('task', task); |
|
|
if(value) params.set('value', value); |
|
|
if(delayed) params.set('delayed', delayed); |
|
|
const res = await fetch('/api/tasks?' + params.toString()); |
|
|
const d = await res.json(); |
|
|
document.getElementById('totalCount').innerText = d.total_count; |
|
|
document.getElementById('doneCount').innerText = d.done_count + ' (' + d.progress_percent.toFixed(1) + '%)'; |
|
|
document.getElementById('avgDuration').innerText = d.avg_duration.toFixed(1) + ' mins'; |
|
|
// populate filters if empty |
|
|
const clientSel = document.getElementById('filterClient'); |
|
|
if(clientSel.options.length <= 1){ |
|
|
clientSel.innerHTML = '<option value=\"\">All</option>'; |
|
|
d.clients.forEach(c => { |
|
|
const o = document.createElement('option'); o.value = c; o.text = c; clientSel.appendChild(o); |
|
|
}); |
|
|
} |
|
|
const phaseSel = document.getElementById('filterPhase'); |
|
|
if(phaseSel.options.length <= 1){ |
|
|
phaseSel.innerHTML = '<option value=\"\">All</option>'; |
|
|
d.phases.forEach(p => { |
|
|
const o = document.createElement('option'); o.value = p; o.text = p; phaseSel.appendChild(o); |
|
|
}); |
|
|
} |
|
|
// populate Task Name filter |
|
|
const taskSel = document.getElementById('filterTask'); |
|
|
if(taskSel.options.length <= 1){ |
|
|
taskSel.innerHTML = '<option value="">All</option>'; |
|
|
d.tasks.map(t => t["Task Name"]).filter((v,i,a)=>a.indexOf(v)===i).forEach(tn => { |
|
|
const o = document.createElement('option'); o.value = tn; o.text = tn; taskSel.appendChild(o); |
|
|
}); |
|
|
} |
|
|
// populate Value filter |
|
|
const valueSel = document.getElementById('filterValue'); |
|
|
if(valueSel.options.length <= 1){ |
|
|
valueSel.innerHTML = '<option value="">All</option>'; |
|
|
d.tasks.map(t => t.Value).filter((v,i,a)=>a.indexOf(v)===i).forEach(vv => { |
|
|
const o = document.createElement('option'); o.value = vv; o.text = vv; valueSel.appendChild(o); |
|
|
}); |
|
|
} |
|
|
// task table |
|
|
const tbody = document.getElementById('taskTable'); tbody.innerHTML = ''; |
|
|
// لو مفيش بيانات |
|
|
if(d.tasks.length === 0){ |
|
|
const r = document.createElement('tr'); |
|
|
r.innerHTML = `<td class='p-2 text-center' colspan='8'>Not Found</td>`; |
|
|
tbody.appendChild(r); |
|
|
|
|
|
// ممكن نرجع القيم الرئيسية للصفر |
|
|
document.getElementById('totalCount').innerText = 0; |
|
|
document.getElementById('doneCount').innerText = '0 (0%)'; |
|
|
document.getElementById('avgDuration').innerText = '0 mins'; |
|
|
|
|
|
// destroy charts لو موجودة |
|
|
if(window.clientsChart) window.clientsChart.destroy(); |
|
|
if(window.avgChart) window.avgChart.destroy(); |
|
|
if(window.valueChart) window.valueChart.destroy(); |
|
|
if(window.timeChart) window.timeChart.destroy(); |
|
|
if(window.phaseChart) window.phaseChart.destroy(); |
|
|
if(window.taskChart) window.taskChart.destroy(); |
|
|
return; // خروج من الدالة |
|
|
} |
|
|
|
|
|
d.tasks.forEach(t => { |
|
|
const r = document.createElement('tr'); |
|
|
r.className = 'border-b'; |
|
|
r.innerHTML = `<td class='p-2'>${t['Task Name']}</td> |
|
|
<td class='p-2'>${t.Client}</td> |
|
|
<td class='p-2'>${t.Phase}</td> |
|
|
<td class='p-2'>${t['creative stage']}</td> |
|
|
<td class='p-2'>${t.Value}</td> |
|
|
<td class='p-2'>${t['Duration (mins)']}</td> |
|
|
<td class='p-2'>${t['Start Time']}</td> |
|
|
<td class='p-2'>${t['End Time']}</td> |
|
|
<td class='p-2'>${t.delayed ? 'Yes' : 'No'}</td>`; |
|
|
tbody.appendChild(r); |
|
|
}); |
|
|
// charts |
|
|
const clientsLabels = d.clients; |
|
|
const clientsCounts = d.client_counts; |
|
|
const avgDurations = d.client_avg_duration; |
|
|
// destroy existing charts if present |
|
|
if(window.clientsChart){ window.clientsChart.destroy(); } |
|
|
if(window.avgChart){ window.avgChart.destroy(); } |
|
|
if(window.valueChart){ window.valueChart.destroy(); } |
|
|
if(window.timeChart){ window.timeChart.destroy(); } |
|
|
if(window.phaseChart) window.phaseChart.destroy(); |
|
|
|
|
|
const ctx1 = document.getElementById('clientsBar').getContext('2d'); |
|
|
window.clientsChart = new Chart(ctx1, { |
|
|
type: 'bar', |
|
|
data: { labels: clientsLabels, datasets: [{ label: 'Tasks', data: clientsCounts, backgroundColor: '#3B82F6' }]}, |
|
|
options: { responsive:true, plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true}}} |
|
|
}); |
|
|
const ctx2 = document.getElementById('avgBar').getContext('2d'); |
|
|
window.avgChart = new Chart(ctx2, { |
|
|
type: 'bar', |
|
|
data: { labels: clientsLabels, datasets: [{ label: 'Avg Duration (mins)', data: avgDurations, backgroundColor: '#10B981' }]}, |
|
|
options: { indexAxis: 'y', responsive:true, plugins:{legend:{display:false}} } |
|
|
}); |
|
|
const ctx3 = document.getElementById('valuePie').getContext('2d'); |
|
|
window.valueChart = new Chart(ctx3, { |
|
|
type: 'pie', |
|
|
data: { labels: d.values_labels, datasets: [{ data: d.values_counts }]}, |
|
|
options: { responsive:true } |
|
|
}); |
|
|
// Avg Duration per Task |
|
|
const taskLabels = [...new Set(d.tasks.map(t => t["Task Name"]))]; |
|
|
const taskAvgDurations = taskLabels.map(tn => { |
|
|
const tasksGroup = d.tasks.filter(t => t["Task Name"] === tn); |
|
|
const durations = tasksGroup.map(t => t["Duration (mins)"] || 0); |
|
|
const avg = durations.reduce((a,b)=>a+b,0)/durations.length; |
|
|
return avg; |
|
|
}); |
|
|
const ctxTask = document.getElementById('taskAvgBar').getContext('2d'); |
|
|
if(window.taskChart) window.taskChart.destroy(); |
|
|
window.taskChart = new Chart(ctxTask, { |
|
|
type: 'bar', |
|
|
data: { labels: taskLabels, datasets:[{label:'Avg Duration (mins)', data: taskAvgDurations, backgroundColor:'#F43F5E'}] }, |
|
|
options:{ responsive:true, plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true}} } |
|
|
}); |
|
|
|
|
|
// احسب متوسط مدة كل Phase |
|
|
const phaseLabels = [...new Set(d.tasks.map(t => t.Phase))]; // كل الـ Phases الموجودة |
|
|
const phaseAvgDurations = phaseLabels.map(ph => { |
|
|
const tasksInPhase = d.tasks.filter(t => t.Phase === ph); |
|
|
const durations = tasksInPhase.map(t => t['Duration (mins)'] || 0); |
|
|
const avg = durations.reduce((a,b)=>a+b,0)/durations.length; |
|
|
return avg; |
|
|
}); |
|
|
const ctx5 = document.getElementById('phaseAvgBar').getContext('2d'); |
|
|
window.phaseChart = new Chart(ctx5, { type:'bar', data:{labels:phaseLabels,datasets:[{label:'Avg Duration (mins)',data:phaseAvgDurations,backgroundColor:'#F59E0B'}]}, options:{responsive:true,plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true}}} }); |
|
|
const ctx4 = document.getElementById('timeline').getContext('2d'); |
|
|
window.timeChart = new Chart(ctx4, { |
|
|
type: 'line', |
|
|
data: { labels: d.timeline.labels, datasets: [{ label: 'Tasks started', data: d.timeline.counts, fill:true, tension:0.3 }]}, |
|
|
options: { responsive:true, scales:{y:{beginAtZero:true}}} |
|
|
}); |
|
|
} |
|
|
function resetFilters(){ |
|
|
document.getElementById('filterClient').value = ''; |
|
|
document.getElementById('filterPhase').value = ''; |
|
|
document.getElementById('filterTask').value = ''; |
|
|
document.getElementById('filterValue').value = ''; |
|
|
document.getElementById('filterDelayed').value = ''; |
|
|
load(); // إعادة تحميل كل البيانات بعد تفريغ الفلاتر |
|
|
} |
|
|
load(); |
|
|
setInterval(load, 5*60*1000); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
import pandas as pd |
|
|
import re |
|
|
import chardet |
|
|
import math |
|
|
|
|
|
def read_and_prepare(csv_path=CSV_PATH): |
|
|
""" |
|
|
Reads CSV and returns cleaned DataFrame: |
|
|
- Handles encoding automatically |
|
|
- Adds 'Creative Stage' if Phase includes image/video |
|
|
- Removes rows not containing image/video |
|
|
- Removes 'Emotion' from Phase |
|
|
""" |
|
|
|
|
|
try: |
|
|
df = pd.read_csv(csv_path, dtype=str, encoding="utf-8-sig").fillna("") |
|
|
except UnicodeDecodeError: |
|
|
with open(csv_path,'rb') as f: |
|
|
raw_data = f.read(50000) |
|
|
detected = chardet.detect(raw_data) |
|
|
encoding_used = detected.get("encoding", "utf-8") |
|
|
print("🔎 Detected encoding:", encoding_used) |
|
|
df = pd.read_csv(csv_path, dtype=str, encoding=encoding_used).fillna("") |
|
|
|
|
|
|
|
|
expected_cols = ["Task ID", "Task Name", "Client", "Phase", "Value", "Start Time", "End Time", "Duration (mins)", "creative stage"] |
|
|
for c in expected_cols: |
|
|
if c not in df.columns: |
|
|
df[c] = "" |
|
|
|
|
|
|
|
|
df["Phase"] = df["Phase"].str.replace(r"^\s*Emotion\s*", "", regex=True).str.strip() |
|
|
|
|
|
|
|
|
df["creative stage"] = df["Phase"].apply( |
|
|
lambda x: "CREATIVE" if re.search(r"(image|video)", str(x).lower()) else "" |
|
|
) |
|
|
|
|
|
|
|
|
df = df[df["Phase"].str.lower().str.contains("image|video", na=False)] |
|
|
|
|
|
|
|
|
def try_parse(s): |
|
|
try: |
|
|
return pd.to_datetime(s, errors="coerce") |
|
|
except Exception: |
|
|
return pd.NaT |
|
|
|
|
|
df["Start_dt"] = df["Start Time"].apply(try_parse) |
|
|
df["End_dt"] = df["End Time"].apply(try_parse) |
|
|
|
|
|
|
|
|
def duration_minutes(row): |
|
|
val = row.get("Duration (mins)") |
|
|
try: |
|
|
if val and str(val).strip() != "": |
|
|
return float(str(val).strip()) |
|
|
except: |
|
|
pass |
|
|
s, e = row.get("Start_dt"), row.get("End_dt") |
|
|
if pd.notna(s) and pd.notna(e): |
|
|
return (e - s).total_seconds() / 60.0 |
|
|
return float("nan") |
|
|
|
|
|
df["Duration (mins)"] = df.apply(duration_minutes, axis=1) |
|
|
|
|
|
|
|
|
done_keywords = ["APPROVED", "DONE", "COMPLETED", "DELIVERED", "PO APPROVAL", "CLIENT APPROVAL"] |
|
|
df["is_done"] = df["Value"].str.upper().apply(lambda v: any(k in v for k in done_keywords)) |
|
|
|
|
|
|
|
|
mean = df["Duration (mins)"].mean(skipna=True) |
|
|
std = df["Duration (mins)"].std(skipna=True) if not math.isnan(mean) else 0 |
|
|
threshold = (mean + std) if not math.isnan(mean) else None |
|
|
def detect_delayed(dur): |
|
|
if dur is None or pd.isna(dur): |
|
|
return False |
|
|
if threshold is None: |
|
|
return False |
|
|
return dur > threshold |
|
|
df["delayed"] = df["Duration (mins)"].apply(detect_delayed) |
|
|
|
|
|
|
|
|
df["start_date"] = df["Start_dt"].dt.date.astype(str).fillna("unknown") |
|
|
|
|
|
return df |
|
|
|
|
|
import numpy as np |
|
|
import math |
|
|
|
|
|
def sanitize_for_json(obj): |
|
|
if isinstance(obj, float): |
|
|
if math.isnan(obj) or math.isinf(obj): |
|
|
return 0 |
|
|
return obj |
|
|
elif isinstance(obj, dict): |
|
|
return {k: sanitize_for_json(v) for k, v in obj.items()} |
|
|
elif isinstance(obj, list): |
|
|
return [sanitize_for_json(v) for v in obj] |
|
|
else: |
|
|
return obj |
|
|
|
|
|
|
|
|
def build_response(df, filter_client=None, filter_phase=None, |
|
|
filter_task=None, filter_value=None, filter_delayed=None): |
|
|
dff = df.copy() |
|
|
if filter_client: dff = dff[dff["Client"]==filter_client] |
|
|
if filter_phase: dff = dff[dff["Phase"]==filter_phase] |
|
|
if filter_task: dff = dff[dff["Task Name"]==filter_task] |
|
|
if filter_value: dff = dff[dff["Value"]==filter_value] |
|
|
if filter_delayed: |
|
|
if filter_delayed.lower()=='yes': dff=dff[dff["delayed"]==True] |
|
|
elif filter_delayed.lower()=='no': dff=dff[dff["delayed"]==False] |
|
|
|
|
|
total_count = len(dff) |
|
|
|
|
|
|
|
|
if total_count == 0: |
|
|
return { |
|
|
"total_count": 0, |
|
|
"done_count": 0, |
|
|
"progress_percent": 0, |
|
|
"avg_duration": 0, |
|
|
"clients": sorted(df["Client"].unique().tolist()), |
|
|
"phases": sorted(df["Phase"].unique().tolist()), |
|
|
"tasks_names": sorted(df["Task Name"].unique().tolist()), |
|
|
"clients_filtered": [], |
|
|
"client_counts": [], |
|
|
"client_avg_duration": [], |
|
|
"values_labels": [], |
|
|
"values_counts": [], |
|
|
"timeline": {"labels": [], "counts": []}, |
|
|
"tasks": [], |
|
|
"message": "Not Found" |
|
|
} |
|
|
|
|
|
|
|
|
done_count = int(dff["is_done"].sum()) |
|
|
progress_percent = (done_count/total_count*100) if total_count else 0 |
|
|
avg_duration = float(dff["Duration (mins)"].mean(skipna=True) or 0) |
|
|
|
|
|
client_counts_series = dff["Client"].value_counts() |
|
|
clients = list(client_counts_series.index) |
|
|
client_counts = [int(x) for x in client_counts_series.values] |
|
|
|
|
|
client_avg = dff.groupby("Client")["Duration (mins)"].mean().reindex(clients).fillna(0).tolist() |
|
|
|
|
|
values_counts_series = dff["Value"].value_counts() |
|
|
values_labels = list(values_counts_series.index) |
|
|
values_counts = [int(x) for x in values_counts_series.values] |
|
|
|
|
|
timeline_series = dff.groupby("start_date").size().sort_index() |
|
|
timeline_labels = list(timeline_series.index) |
|
|
timeline_counts = [int(x) for x in timeline_series.values] |
|
|
|
|
|
tasks = dff[["Task ID","Task Name","Client","Phase","creative stage","Value","Start Time","End Time","Duration (mins)","delayed"]].fillna("").to_dict(orient="records") |
|
|
|
|
|
clients_all = sorted(df["Client"].unique().tolist()) |
|
|
phases_all = sorted(df["Phase"].unique().tolist()) |
|
|
tasks_names = sorted(df["Task Name"].unique().tolist()) |
|
|
|
|
|
resp = { |
|
|
"total_count": total_count, |
|
|
"done_count": done_count, |
|
|
"progress_percent": progress_percent, |
|
|
"avg_duration": avg_duration, |
|
|
"clients": clients_all, |
|
|
"phases": phases_all, |
|
|
"tasks_names": tasks_names, |
|
|
"clients_filtered": clients, |
|
|
"client_counts": client_counts, |
|
|
"client_avg_duration": [round(x or 0,1) for x in client_avg], |
|
|
"values_labels": values_labels, |
|
|
"values_counts": values_counts, |
|
|
"timeline": {"labels": timeline_labels, "counts": timeline_counts}, |
|
|
"tasks": tasks |
|
|
} |
|
|
return resp |
|
|
|
|
|
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse) |
|
|
def index(): |
|
|
return HTML_TEMPLATE |
|
|
|
|
|
@app.get("/api/tasks") |
|
|
def api_tasks( |
|
|
client: str = Query(None), |
|
|
phase: str = Query(None), |
|
|
task: str = Query(None), |
|
|
value: str = Query(None), |
|
|
delayed: str = Query(None) |
|
|
): |
|
|
df = read_and_prepare() |
|
|
resp = build_response( |
|
|
df, |
|
|
filter_client=client, |
|
|
filter_phase=phase, |
|
|
filter_task=task, |
|
|
filter_value=value, |
|
|
filter_delayed=delayed |
|
|
) |
|
|
resp = sanitize_for_json(resp) |
|
|
return JSONResponse(resp) |
|
|
|
|
|
@app.get("/report/pdf") |
|
|
def report_pdf(): |
|
|
df = read_and_prepare() |
|
|
resp = build_response(df) |
|
|
buffer = io.BytesIO() |
|
|
doc = SimpleDocTemplate(buffer, pagesize=A4) |
|
|
styles = getSampleStyleSheet() |
|
|
|
|
|
elements = [Paragraph('Task Phase Report', styles['Title']), Spacer(1, 8)] |
|
|
elements.append(Paragraph(f"Total tasks: {resp['total_count']}", styles['Normal'])) |
|
|
elements.append(Paragraph(f"Completed: {resp['done_count']} ({resp['progress_percent']:.1f}%)", styles['Normal'])) |
|
|
elements.append(Paragraph(f"Average duration (mins): {resp['avg_duration']:.1f}", styles['Normal'])) |
|
|
elements.append(Spacer(1, 12)) |
|
|
|
|
|
|
|
|
data_table = [["Task Name", "Client", "Phase", "Value", "Duration (mins)", "Start", "End", "Delayed"]] |
|
|
for row in resp['tasks'][:200]: |
|
|
data_table.append([ |
|
|
row.get("Task Name", ""), |
|
|
row.get("Client", ""), |
|
|
row.get("Phase", ""), |
|
|
row.get("Value", ""), |
|
|
f"{row.get('Duration (mins)', ''):.1f}" if isinstance(row.get('Duration (mins)'), (int, float)) else row.get('Duration (mins)', ''), |
|
|
row.get("Start Time", ""), |
|
|
row.get("End Time", ""), |
|
|
"Yes" if row.get("delayed") else "No" |
|
|
]) |
|
|
|
|
|
table = Table(data_table, repeatRows=1, colWidths=[80,70,60,70,60,80,80,45]) |
|
|
table.setStyle(TableStyle([ |
|
|
('BACKGROUND',(0,0),(-1,0),colors.lightgrey), |
|
|
('GRID',(0,0),(-1,-1),0.25,colors.grey), |
|
|
('ALIGN',(0,0),(-1,-1),'LEFT'), |
|
|
('FONT',(0,0),(-1,0),'Helvetica-Bold') |
|
|
])) |
|
|
elements.append(table) |
|
|
doc.build(elements) |
|
|
buffer.seek(0) |
|
|
return StreamingResponse(buffer, media_type='application/pdf', |
|
|
headers={'Content-Disposition':'attachment; filename="task_phase_report.pdf"'}) |
|
|
|
|
|
@app.get("/report/excel") |
|
|
def report_excel(): |
|
|
df = read_and_prepare() |
|
|
resp = build_response(df) |
|
|
wb = Workbook() |
|
|
ws = wb.active |
|
|
ws.title = "Task Report" |
|
|
|
|
|
headers = ["Task ID","Task Name","Client","Phase","Value","Duration (mins)","Start Time","End Time","Delayed"] |
|
|
ws.append(headers) |
|
|
for row in resp['tasks']: |
|
|
ws.append([ |
|
|
row.get("Task ID",""), |
|
|
row.get("Task Name",""), |
|
|
row.get("Client",""), |
|
|
row.get("Phase",""), |
|
|
row.get("Value",""), |
|
|
row.get("Duration (mins)",""), |
|
|
row.get("Start Time",""), |
|
|
row.get("End Time",""), |
|
|
"Yes" if row.get("delayed") else "No" |
|
|
]) |
|
|
buf = io.BytesIO() |
|
|
wb.save(buf) |
|
|
buf.seek(0) |
|
|
return StreamingResponse(buf, media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
|
|
headers={'Content-Disposition':'attachment; filename="task_phase_report.xlsx"'}) |
|
|
if __name__ == "__main__": |
|
|
uvicorn.run("app:app", host="0.0.0.0", port=7860) |
|
|
|