DashBorad / app.py
yusiff's picture
Update app.py
5ee2219 verified
# app.py
"""
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" # file should be in the same folder as app.py
# Simpl HTML template (English labels). Uses Chart.js from CDN.
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>
"""
########### Data processing utilities ###########
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) # نقرأ أول 50 ألف بايت بس
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] = ""
# نظّف عمود Phase: شيل كلمة Emotion (لو في الأول)
df["Phase"] = df["Phase"].str.replace(r"^\s*Emotion\s*", "", regex=True).str.strip()
# أضف عمود Creative Stage
df["creative stage"] = df["Phase"].apply(
lambda x: "CREATIVE" if re.search(r"(image|video)", str(x).lower()) else ""
)
# خليك بس في الصفوف اللي Phase فيها image أو video
df = df[df["Phase"].str.lower().str.contains("image|video", na=False)]
# Parse Start/End كتواريخ
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)
# Duration (mins)
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)
# delayed flag
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))
# delayed detection: mark as delayed if duration > mean + std (per Phase could be better)
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)
# prepare a start_date string for timeline grouping (YYYY-MM-DD)
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
########### End utilities ###########
@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))
# add table head
data_table = [["Task Name", "Client", "Phase", "Value", "Duration (mins)", "Start", "End", "Delayed"]]
for row in resp['tasks'][:200]: # limit rows in pdf
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)