Spaces:
Sleeping
Sleeping
Upload data_app.py
Browse files- data_app.py +316 -85
data_app.py
CHANGED
|
@@ -1,87 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import streamlit as st
|
| 2 |
import pandas as pd
|
| 3 |
-
|
| 4 |
-
import
|
| 5 |
-
from
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==========================================
|
| 2 |
+
# app.py β FieldTech Pro | Streamlit App
|
| 3 |
+
# ==========================================
|
| 4 |
+
# Author: OpenAI GPT-5
|
| 5 |
+
# Date: 2026-05-09
|
| 6 |
+
# ==========================================
|
| 7 |
+
|
| 8 |
import streamlit as st
|
| 9 |
import pandas as pd
|
| 10 |
+
import plotly.express as px
|
| 11 |
+
import altair as alt
|
| 12 |
+
from datetime import datetime, timedelta
|
| 13 |
+
import os, json, uuid, time
|
| 14 |
+
from io import BytesIO
|
| 15 |
+
from PIL import Image
|
| 16 |
+
import base64
|
| 17 |
+
from weasyprint import HTML
|
| 18 |
+
|
| 19 |
+
# ==========================================
|
| 20 |
+
# INITIAL SETUP & FOLDERS
|
| 21 |
+
# ==========================================
|
| 22 |
+
APP_NAME = "FieldTech Pro β International Service Technician Tracker"
|
| 23 |
+
BASE_DIR = os.path.join(os.getcwd(), "FieldTechPro_data")
|
| 24 |
+
|
| 25 |
+
os.makedirs(BASE_DIR, exist_ok=True)
|
| 26 |
+
for sub in ["projects/active", "projects/finished", "media"]:
|
| 27 |
+
os.makedirs(os.path.join(BASE_DIR, sub), exist_ok=True)
|
| 28 |
+
|
| 29 |
+
# ==========================================
|
| 30 |
+
# PAGE CONFIG & STYLES
|
| 31 |
+
# ==========================================
|
| 32 |
+
st.set_page_config(page_title=APP_NAME, layout="wide", page_icon="π§°")
|
| 33 |
+
|
| 34 |
+
st.markdown(
|
| 35 |
+
"""
|
| 36 |
+
<style>
|
| 37 |
+
body {font-family: 'Inter', sans-serif; background-color: #f8f9fb;}
|
| 38 |
+
.big-button button {font-size:18px !important; padding:15px 25px !important;}
|
| 39 |
+
.card {
|
| 40 |
+
background-color: white;
|
| 41 |
+
border-radius: 8px;
|
| 42 |
+
padding: 20px;
|
| 43 |
+
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
| 44 |
+
text-align: center;
|
| 45 |
+
}
|
| 46 |
+
.metric-title {font-weight: 600; font-size: 18px;}
|
| 47 |
+
.metric-value {font-size: 22px; color: #0d6efd;}
|
| 48 |
+
</style>
|
| 49 |
+
""",
|
| 50 |
+
unsafe_allow_html=True,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# ==========================================
|
| 54 |
+
# HELPERS
|
| 55 |
+
# ==========================================
|
| 56 |
+
|
| 57 |
+
def gen_project_id() -> str:
|
| 58 |
+
return f"FT-{datetime.now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:4]}"
|
| 59 |
+
|
| 60 |
+
def get_active_projects():
|
| 61 |
+
path = os.path.join(BASE_DIR, "projects/active")
|
| 62 |
+
files = [f for f in os.listdir(path) if f.endswith(".json")]
|
| 63 |
+
return [os.path.splitext(f)[0] for f in files]
|
| 64 |
+
|
| 65 |
+
def load_project(project_id):
|
| 66 |
+
pj = os.path.join(BASE_DIR, "projects/active", f"{project_id}.json")
|
| 67 |
+
if not os.path.exists(pj):
|
| 68 |
+
pj = os.path.join(BASE_DIR, "projects/finished", f"{project_id}.json")
|
| 69 |
+
if not os.path.exists(pj):
|
| 70 |
+
return None
|
| 71 |
+
with open(pj, "r") as f:
|
| 72 |
+
return json.load(f)
|
| 73 |
+
|
| 74 |
+
def save_project(project):
|
| 75 |
+
pid = project["project_id"]
|
| 76 |
+
folder = "active" if project.get("status") != "completed" else "finished"
|
| 77 |
+
path_json = os.path.join(BASE_DIR, f"projects/{folder}/{pid}.json")
|
| 78 |
+
with open(path_json, "w") as f:
|
| 79 |
+
json.dump(project, f, indent=4)
|
| 80 |
+
|
| 81 |
+
def move_to_finished(project):
|
| 82 |
+
project["status"] = "completed"
|
| 83 |
+
save_project(project)
|
| 84 |
+
active_path = os.path.join(BASE_DIR, "projects/active", f"{project['project_id']}.json")
|
| 85 |
+
finished_path = os.path.join(BASE_DIR, "projects/finished", f"{project['project_id']}.json")
|
| 86 |
+
if os.path.exists(active_path):
|
| 87 |
+
os.rename(active_path, finished_path)
|
| 88 |
+
|
| 89 |
+
def ensure_media_folder(project_id):
|
| 90 |
+
path = os.path.join(BASE_DIR, "media", project_id)
|
| 91 |
+
os.makedirs(path, exist_ok=True)
|
| 92 |
+
return path
|
| 93 |
+
|
| 94 |
+
def add_expense(project, category, amount, currency="USD"):
|
| 95 |
+
exp = project.get("expenses", [])
|
| 96 |
+
exp.append({"date": datetime.now().isoformat(), "category": category, "amount": float(amount), "currency": currency})
|
| 97 |
+
project["expenses"] = exp
|
| 98 |
+
|
| 99 |
+
def fake_ocr_receipt(image):
|
| 100 |
+
# Placeholder for Hugging Face OCR
|
| 101 |
+
# In production, call Donut or TrOCR model here.
|
| 102 |
+
return pd.DataFrame([
|
| 103 |
+
{"Date": datetime.now().strftime("%Y-%m-%d"), "Merchant": "ACME Tools", "Total": 245.50, "Currency": "USD", "Item": "Replacement Kit"}
|
| 104 |
+
])
|
| 105 |
+
|
| 106 |
+
def fake_video_transcribe(video_bytes):
|
| 107 |
+
# Placeholder transcription (replace with actual model call)
|
| 108 |
+
return "Technician performed diagnostics, replaced fuse, verified operation, and closed service ticket."
|
| 109 |
+
|
| 110 |
+
# ==========================================
|
| 111 |
+
# SIDEBAR NAVIGATION
|
| 112 |
+
# ==========================================
|
| 113 |
+
menu = st.sidebar.radio(
|
| 114 |
+
"Navigation",
|
| 115 |
+
["π Home","β New Project","π Existing Projects","π Prospect","π Documentation","π Reports"]
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# ==========================================
|
| 119 |
+
# HOME
|
| 120 |
+
# ==========================================
|
| 121 |
+
if menu == "π Home":
|
| 122 |
+
st.title("π Dashboard")
|
| 123 |
+
|
| 124 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 125 |
+
col1.markdown(f"<div class='card'><div class='metric-title'>Active Trips</div><div class='metric-value'>{len(get_active_projects())}</div></div>", unsafe_allow_html=True)
|
| 126 |
+
col2.markdown("<div class='card'><div class='metric-title'>Billable Hours (Week)</div><div class='metric-value'>42.5</div></div>", unsafe_allow_html=True)
|
| 127 |
+
col3.markdown("<div class='card'><div class='metric-title'>Expenses Pending</div><div class='metric-value'>$560</div></div>", unsafe_allow_html=True)
|
| 128 |
+
col4.markdown("<div class='card'><div class='metric-title'>Completed Projects</div><div class='metric-value'>12</div></div>", unsafe_allow_html=True)
|
| 129 |
+
|
| 130 |
+
# Charts
|
| 131 |
+
st.subheader("Hours Trend")
|
| 132 |
+
df_hours = pd.DataFrame({
|
| 133 |
+
"Day": [d.strftime("%a") for d in [datetime.now() - timedelta(days=i) for i in range(6,-1,-1)]],
|
| 134 |
+
"Hours": [6,7,8,9,7,5,6]
|
| 135 |
+
})
|
| 136 |
+
fig = px.line(df_hours, x="Day", y="Hours", markers=True)
|
| 137 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 138 |
+
|
| 139 |
+
st.subheader("Expense Breakdown")
|
| 140 |
+
exp_df = pd.DataFrame({"Category":["Travel","Meals","Tools","Hotels"],"Amount":[300,120,250,400]})
|
| 141 |
+
chart = alt.Chart(exp_df).mark_arc(innerRadius=50).encode(theta="Amount", color="Category")
|
| 142 |
+
st.altair_chart(chart, use_container_width=True)
|
| 143 |
+
|
| 144 |
+
st.markdown("<div class='big-button'>", unsafe_allow_html=True)
|
| 145 |
+
st.button("β Start New Project")
|
| 146 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 147 |
+
|
| 148 |
+
# ==========================================
|
| 149 |
+
# NEW PROJECT
|
| 150 |
+
# ==========================================
|
| 151 |
+
elif menu == "β New Project":
|
| 152 |
+
st.title("β New Project")
|
| 153 |
+
if "current_project" not in st.session_state:
|
| 154 |
+
st.session_state.current_project = {"project_id": gen_project_id(), "status": "active"}
|
| 155 |
+
|
| 156 |
+
proj = st.session_state.current_project
|
| 157 |
+
ensure_media_folder(proj["project_id"])
|
| 158 |
+
|
| 159 |
+
st.subheader("Project Header")
|
| 160 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 161 |
+
proj["client"] = c1.text_input("Client Name", proj.get("client",""))
|
| 162 |
+
proj["location"] = c2.text_input("Site/Location", proj.get("location",""))
|
| 163 |
+
proj["country"] = c3.text_input("Country", proj.get("country",""))
|
| 164 |
+
proj["technician"] = c4.text_input("Technician Name", proj.get("technician",""))
|
| 165 |
+
|
| 166 |
+
proj["start_date"] = st.date_input("Start Date", proj.get("start_date", datetime.now().date()))
|
| 167 |
+
proj["end_date"] = st.date_input("End Date", proj.get("end_date", datetime.now().date()))
|
| 168 |
+
|
| 169 |
+
st.markdown("---")
|
| 170 |
+
st.subheader("Trip Log & Travel")
|
| 171 |
+
colA, colB = st.columns(2)
|
| 172 |
+
proj["travel_type"] = colA.selectbox("Travel Type", ["Road","Air","Train","Mixed"], index=0)
|
| 173 |
+
proj["distance_km"] = colB.number_input("Distance (km)", value=float(proj.get("distance_km",0.0)))
|
| 174 |
+
proj["distance_miles"] = round(proj["distance_km"] * 0.621, 2)
|
| 175 |
+
st.caption(f"β {proj['distance_miles']} miles")
|
| 176 |
+
|
| 177 |
+
with st.expander("Labor Hours"):
|
| 178 |
+
proj["task_category"] = st.selectbox("Task Category", ["Diagnostic","Repair","Testing","Training","Waiting"])
|
| 179 |
+
proj["hours_worked"] = st.number_input("Hours Worked", value=float(proj.get("hours_worked",0.0)))
|
| 180 |
+
if proj["hours_worked"] > 8:
|
| 181 |
+
st.warning("Overtime detected!")
|
| 182 |
+
|
| 183 |
+
with st.expander("Hotel & Accommodation"):
|
| 184 |
+
proj["hotel_rate"] = st.number_input("Nightly Rate (USD)", value=float(proj.get("hotel_rate",0.0)))
|
| 185 |
+
proj["nights"] = st.number_input("Nights", value=int(proj.get("nights",0)))
|
| 186 |
+
proj["hotel_total"] = proj["hotel_rate"] * proj["nights"]
|
| 187 |
+
st.caption(f"Total: ${proj['hotel_total']:.2f}")
|
| 188 |
+
|
| 189 |
+
with st.expander("Expenses"):
|
| 190 |
+
cat = st.selectbox("Category", ["Travel","Meal","Tools","Other"])
|
| 191 |
+
amt = st.number_input("Amount", min_value=0.0)
|
| 192 |
+
cur = st.text_input("Currency", "USD")
|
| 193 |
+
if st.button("Add Expense"):
|
| 194 |
+
add_expense(proj, cat, amt, cur)
|
| 195 |
+
save_project(proj)
|
| 196 |
+
if proj.get("expenses"):
|
| 197 |
+
st.table(pd.DataFrame(proj["expenses"]))
|
| 198 |
+
|
| 199 |
+
with st.expander("Media Capture"):
|
| 200 |
+
img = st.camera_input("Take Photo")
|
| 201 |
+
if img:
|
| 202 |
+
img_path = os.path.join(ensure_media_folder(proj["project_id"]), f"photo_{int(time.time())}.jpg")
|
| 203 |
+
Image.open(img).save(img_path)
|
| 204 |
+
st.success("Photo saved.")
|
| 205 |
+
vid = st.file_uploader("Upload Video", type=["mp4","mov"])
|
| 206 |
+
if vid:
|
| 207 |
+
vid_path = os.path.join(ensure_media_folder(proj["project_id"]), vid.name)
|
| 208 |
+
open(vid_path,"wb").write(vid.read())
|
| 209 |
+
st.success("Video uploaded.")
|
| 210 |
+
proj["notes"] = st.text_area("Notes / Voice-to-text field")
|
| 211 |
+
|
| 212 |
+
# Auto-Save
|
| 213 |
+
if int(time.time()) % 30 == 0:
|
| 214 |
+
save_project(proj)
|
| 215 |
+
|
| 216 |
+
st.markdown("---")
|
| 217 |
+
col_end1, col_end2 = st.columns(2)
|
| 218 |
+
if col_end1.button("πΎ Save & Continue Later"):
|
| 219 |
+
save_project(proj)
|
| 220 |
+
st.success("Project saved.")
|
| 221 |
+
if col_end2.button("β
Mark Project Complete"):
|
| 222 |
+
move_to_finished(proj)
|
| 223 |
+
st.success("Project marked complete and moved to finished folder.")
|
| 224 |
+
|
| 225 |
+
# ==========================================
|
| 226 |
+
# EXISTING PROJECTS
|
| 227 |
+
# ==========================================
|
| 228 |
+
elif menu == "π Existing Projects":
|
| 229 |
+
st.title("π Existing Projects")
|
| 230 |
+
active = get_active_projects()
|
| 231 |
+
if active:
|
| 232 |
+
sel = st.selectbox("Select Project", active)
|
| 233 |
+
data = load_project(sel)
|
| 234 |
+
st.json(data)
|
| 235 |
+
else:
|
| 236 |
+
st.info("No active projects found.")
|
| 237 |
+
|
| 238 |
+
# ==========================================
|
| 239 |
+
# PROSPECT
|
| 240 |
+
# ==========================================
|
| 241 |
+
elif menu == "π Prospect":
|
| 242 |
+
st.title("π Prospect Capture")
|
| 243 |
+
new_client = st.text_input("Prospective Client")
|
| 244 |
+
loc = st.text_input("Location")
|
| 245 |
+
service = st.text_area("Service Summary")
|
| 246 |
+
if st.button("Save Prospect"):
|
| 247 |
+
dfp = pd.DataFrame([{"Client":new_client, "Location":loc, "Service":service, "Date":datetime.now().isoformat()}])
|
| 248 |
+
dfp.to_csv(os.path.join(BASE_DIR,"prospects.csv"), mode="a", header=not os.path.exists(os.path.join(BASE_DIR,"prospects.csv")), index=False)
|
| 249 |
+
st.success("Prospect saved.")
|
| 250 |
+
|
| 251 |
+
# ==========================================
|
| 252 |
+
# DOCUMENTATION (OCR + TRANSCRIPTION)
|
| 253 |
+
# ==========================================
|
| 254 |
+
elif menu == "π Documentation":
|
| 255 |
+
st.title("π Documentation & Media")
|
| 256 |
+
|
| 257 |
+
pid_list = get_active_projects() + [f for f in os.listdir(os.path.join(BASE_DIR,"projects/finished")) if f.endswith(".json")]
|
| 258 |
+
pid_list = [os.path.splitext(f)[0] for f in pid_list]
|
| 259 |
+
sel_project = st.selectbox("Select Project", pid_list)
|
| 260 |
+
if sel_project:
|
| 261 |
+
path_media = ensure_media_folder(sel_project)
|
| 262 |
+
st.subheader("Media Gallery")
|
| 263 |
+
imgs = [f for f in os.listdir(path_media) if f.lower().endswith(".jpg")]
|
| 264 |
+
for img_path in imgs:
|
| 265 |
+
st.image(os.path.join(path_media,img_path), width=250)
|
| 266 |
+
|
| 267 |
+
st.subheader("Receipt OCR")
|
| 268 |
+
receipt = st.file_uploader("Upload Receipt Image", type=["jpg","png"])
|
| 269 |
+
if receipt:
|
| 270 |
+
st.image(receipt)
|
| 271 |
+
data = fake_ocr_receipt(receipt)
|
| 272 |
+
st.table(data)
|
| 273 |
+
if st.button("Add Extracted Data to Expenses"):
|
| 274 |
+
proj = load_project(sel_project)
|
| 275 |
+
for _, r in data.iterrows():
|
| 276 |
+
add_expense(proj, "Receipt", r["Total"], r["Currency"])
|
| 277 |
+
save_project(proj)
|
| 278 |
+
st.success("Extracted data added.")
|
| 279 |
+
|
| 280 |
+
st.subheader("Video Transcription")
|
| 281 |
+
video = st.file_uploader("Upload Video for Transcription", type=["mp4","mov"])
|
| 282 |
+
if video:
|
| 283 |
+
text = fake_video_transcribe(video.read())
|
| 284 |
+
st.text_area("Transcribed Text", text)
|
| 285 |
+
|
| 286 |
+
# ==========================================
|
| 287 |
+
# REPORTS
|
| 288 |
+
# ==========================================
|
| 289 |
+
elif menu == "π Reports":
|
| 290 |
+
st.title("π Reports")
|
| 291 |
+
fins = [f for f in os.listdir(os.path.join(BASE_DIR,"projects/finished")) if f.endswith(".json")]
|
| 292 |
+
if not fins:
|
| 293 |
+
st.info("No completed projects yet.")
|
| 294 |
+
else:
|
| 295 |
+
sel = st.selectbox("Select Finished Project", [os.path.splitext(f)[0] for f in fins])
|
| 296 |
+
proj = load_project(sel)
|
| 297 |
+
st.json(proj)
|
| 298 |
+
|
| 299 |
+
if st.button("π Generate Full Service Report"):
|
| 300 |
+
# create HTML report
|
| 301 |
+
html = f"""
|
| 302 |
+
<h1>Service Report</h1>
|
| 303 |
+
<h3>{proj.get('client','')}</h3>
|
| 304 |
+
<p>Project ID: {proj['project_id']} | {proj.get('location','')}</p>
|
| 305 |
+
<h4>Summary</h4>
|
| 306 |
+
<ul>
|
| 307 |
+
<li>Technician: {proj.get('technician','')}</li>
|
| 308 |
+
<li>Start: {proj.get('start_date')}</li>
|
| 309 |
+
<li>End: {proj.get('end_date')}</li>
|
| 310 |
+
</ul>
|
| 311 |
+
<h4>Expenses</h4>
|
| 312 |
+
{pd.DataFrame(proj.get('expenses',[])).to_html(index=False)}
|
| 313 |
+
<h4>Notes</h4>
|
| 314 |
+
<p>{proj.get('notes','')}</p>
|
| 315 |
+
"""
|
| 316 |
+
pdf_bytes = HTML(string=html).write_pdf()
|
| 317 |
+
st.download_button("Download Report PDF", pdf_bytes, file_name=f"{proj['project_id']}_report.pdf")
|
| 318 |
+
|