Spaces:
Running
Running
reset button needs to clear all..
Browse files- README.md +9 -5
- index.html +1472 -19
README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: CostMaster Pro 🔥
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: green
|
| 5 |
+
emoji: 🐳
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- deepsite-v3
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Welcome to your new DeepSite project!
|
| 13 |
+
This project was created with [DeepSite](https://deepsite.hf.co).
|
| 14 |
+
|
index.html
CHANGED
|
@@ -1,19 +1,1472 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Cost Master – S‑Curve</title>
|
| 7 |
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 3v18h18' stroke='%239ab0d8' fill='none'/%3E%3Cpath d='M6 15l4-5 3 3 5-7' stroke='%239ab0d8' fill='none'/%3E%3C/svg%3E"/>
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js"></script>
|
| 11 |
+
<script src="https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js"></script>
|
| 12 |
+
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
| 13 |
+
<style>
|
| 14 |
+
:root{--bg:#0b1222;--panel:#0e1a32;--text:#e7eefc;--muted:#a3b5d9;--border:#233255;--teal:#22e1e8;--blue:#7ad7ff;--green:#86efac;--amber:#ffc455;--purple:#a87fff;--pink:#f08ac3;--orange:#ffa055;}
|
| 15 |
+
*{box-sizing:border-box}
|
| 16 |
+
body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif;margin:0}
|
| 17 |
+
.wrap{max-width:1280px;margin:0 auto}
|
| 18 |
+
header{position:sticky;top:0;z-index:10;background:rgba(11,18,34,.92);backdrop-filter:blur(6px);border-bottom:1px solid var(--border)}
|
| 19 |
+
.brand{display:flex;align-items:flex-end;gap:.55rem;padding:10px 0}
|
| 20 |
+
.brand .logo{width:32px;height:32px;border-radius:8px;background:#0f2042;border:1px solid #284475;display:flex;align-items:center;justify-content:center}
|
| 21 |
+
.brand .logo svg{width:20px;height:20px;stroke:#a9bfe6;fill:none;stroke-width:2}
|
| 22 |
+
.brand .title{font-size:30px;line-height:1;font-weight:700;background:linear-gradient(180deg,#e6ecff,#a0b5df);-webkit-background-clip:text;color:transparent}
|
| 23 |
+
.brand .subtitle{font-size:12px;color:#a6b9de;margin-top:2px}
|
| 24 |
+
.btn{padding:.45rem .7rem;border-radius:10px;border:1px solid var(--border);background:#0e1e3b;color:#e7eefc;font-weight:600;font-size:.9rem}
|
| 25 |
+
.btn.warning{background:#f5a524;color:#142036;border-color:#f0aa3a}
|
| 26 |
+
.btn.danger{background:#943535;border-color:#b24b4b}
|
| 27 |
+
.filename{font-size:.85rem;color:#c5d3f1;opacity:.85}
|
| 28 |
+
#file{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
|
| 29 |
+
.card{background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.015));border:1px solid var(--border);border-radius:14px;padding:1rem; margin-bottom: 1rem}
|
| 30 |
+
.section{display:flex;align-items:center;justify-content:space-between;margin:6px 0 8px}
|
| 31 |
+
.section h2{font-size:1rem;opacity:.95}
|
| 32 |
+
.hint{font-size:.8rem;color:var(--muted)}
|
| 33 |
+
/* KPI */
|
| 34 |
+
.kpi .label{color:var(--muted);font-size:.82rem;margin-bottom:.35rem;display:flex;gap:.45rem;align-items:center}
|
| 35 |
+
.kpi .value{font-weight:700;font-size:1.1rem;line-height:1.25;color:#dbe6ff;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;letter-spacing:.2px}
|
| 36 |
+
.kpi .value.small{font-size:1.02rem}
|
| 37 |
+
.kpi .note{margin-top:.25rem;font-size:.86rem;line-height:1.2;color:var(--muted);font-weight:500;letter-spacing:.15px}
|
| 38 |
+
.kpi-grid{display:grid;grid-template-columns:repeat(4, minmax(220px,1fr));gap:12px}
|
| 39 |
+
@media (max-width: 1100px){ .kpi-grid{grid-template-columns:repeat(auto-fit,minmax(240px,1fr));} }
|
| 40 |
+
/* Pills */
|
| 41 |
+
.grid-fit{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
|
| 42 |
+
.badge{display:flex;align-items:center;gap:10px;background:#0f1e39;border:1px solid var(--border);padding:.45rem .7rem;border-radius:999px;min-width:220px}
|
| 43 |
+
.badge .dot{width:8px;height:8px;border-radius:999px;background:var(--teal)}
|
| 44 |
+
.badge .label{color:var(--muted);font-size:.9rem}
|
| 45 |
+
.badge .val{font-variant-numeric:tabular-nums;font-weight:700;white-space:nowrap}
|
| 46 |
+
/* Range slider */
|
| 47 |
+
.track{height:8px;background:#0f1e39;border:1px solid var(--border);border-radius:999px;position:relative}
|
| 48 |
+
.fill{position:absolute;height:8px;background:linear-gradient(90deg,#22e1e8,#7ad7ff);border-radius:999px}
|
| 49 |
+
.range{pointer-events:none;position:absolute;height:0;width:100%;-webkit-appearance:none}
|
| 50 |
+
.range::-webkit-slider-thumb{pointer-events:auto;width:14px;height:14px;border-radius:50%;background:var(--amber);-webkit-appearance:none;border:2px solid #13233f}
|
| 51 |
+
.chartbox{height:320px}
|
| 52 |
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
|
| 53 |
+
|
| 54 |
+
.compare-pill{display:inline-flex;align-items:center;gap:6px;padding:.2rem .45rem;border-radius:999px;border:1px solid var(--border);background:#0f1e39;font-size:.8rem}
|
| 55 |
+
.compare-dot{width:9px;height:9px;border-radius:999px;display:inline-block}
|
| 56 |
+
.compare-pill .val{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;opacity:.9}
|
| 57 |
+
|
| 58 |
+
.compare-legend-bottom{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:6px}
|
| 59 |
+
|
| 60 |
+
/* ---- S‑Curve Loader ---- */
|
| 61 |
+
.cm-loader{position:fixed;inset:0;background:rgba(9,14,28,.78);backdrop-filter:blur(6px);
|
| 62 |
+
display:flex;align-items:center;justify-content:center;z-index:70}
|
| 63 |
+
.cm-loader-card{width:min(780px,92vw);border:1px solid var(--border);border-radius:18px;
|
| 64 |
+
background:linear-gradient(180deg,rgba(255,255,255,.06),rgba(255,255,255,.02));
|
| 65 |
+
box-shadow:0 10px 40px rgba(0,0,0,.35); padding:18px 20px}
|
| 66 |
+
.cm-hero{width:100%;height:120px;display:block}
|
| 67 |
+
.cm-title{font-weight:700;letter-spacing:.2px}
|
| 68 |
+
.cm-sub{color:var(--muted);margin-top:2px}
|
| 69 |
+
.cm-loader-body{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center}
|
| 70 |
+
.cm-progress{grid-column:1/2;height:10px;border-radius:999px;background:#0f1e39;border:1px solid var(--border);overflow:hidden;margin-top:6px}
|
| 71 |
+
.cm-bar{height:100%;width:0;background:linear-gradient(90deg,#22e1e8,#7ad7ff);transition:width .35s ease}
|
| 72 |
+
.cm-pct{grid-column:2/3;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
| 73 |
+
/* Curve draw + moving dot along path */
|
| 74 |
+
@keyframes cmDraw { from { stroke-dashoffset: 800; } to { stroke-dashoffset: 0; } }
|
| 75 |
+
@keyframes cmDash { from { stroke-dasharray: 1 10; } to { stroke-dasharray: 12 10; } }
|
| 76 |
+
@keyframes cmGlow { 0%,100% { filter: drop-shadow(0 0 0px #00e4ff);} 50% { filter: drop-shadow(0 0 6px #00e4ff);} }
|
| 77 |
+
|
| 78 |
+
/* --- loading toast --- */
|
| 79 |
+
.cm-toast{position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:80}
|
| 80 |
+
.cm-toast-card{display:flex;align-items:center;gap:8px;background:rgba(16,29,56,.92);
|
| 81 |
+
border:1px solid var(--border);padding:8px 12px;border-radius:10px;
|
| 82 |
+
box-shadow:0 6px 18px rgba(0,0,0,.35);color:#d9e7ff;font-weight:600}
|
| 83 |
+
.cm-dot{width:10px;height:10px;border-radius:50%;background:conic-gradient(#22e1e8,#7ad7ff);
|
| 84 |
+
animation: cmPulse 1s ease-in-out infinite alternate}
|
| 85 |
+
@keyframes cmPulse{from{filter:drop-shadow(0 0 0px #00e4ff)}to{filter:drop-shadow(0 0 6px #00e4ff)}}
|
| 86 |
+
</style>
|
| 87 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
| 88 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
| 89 |
+
</head>
|
| 90 |
+
<body>
|
| 91 |
+
<header class="px-6">
|
| 92 |
+
<div class="wrap flex items-center justify-between gap-3">
|
| 93 |
+
<div class="brand">
|
| 94 |
+
<div class="logo"><svg viewBox="0 0 24 24"><path d='M3 3v18h18'/><path d='M6 15l4-5 3 3 5-7'/></svg></div>
|
| 95 |
+
<div><div class="title">Cost Master</div><div class="subtitle">Advanced S‑Curve Analysis & Project Management</div></div>
|
| 96 |
+
</div>
|
| 97 |
+
<div class="flex items-center gap-2">
|
| 98 |
+
<label id="fileLabel" for="file" class="btn warning cursor-pointer" role="button" tabindex="0">+ Choose File</label>
|
| 99 |
+
<span id="filename" class="filename">No file selected</span>
|
| 100 |
+
<input id="file" type="file" accept=".xlsx,.xls,.xlsm,.csv,.XLSX,.XLS,.XLSM,.CSV"/>
|
| 101 |
+
<button id="exportCsv" class="btn">Export Daily CSV</button>
|
| 102 |
+
<button id="exportPdf" class="btn ml-2">Export Report PDF</button>
|
| 103 |
+
<button id="resetAll" class="btn danger">Reset</button>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</header>
|
| 107 |
+
<!-- Tiny "please wait" toast -->
|
| 108 |
+
<div id="cmToast" class="cm-toast" style="display:none;">
|
| 109 |
+
<div class="cm-toast-card">
|
| 110 |
+
<svg id="uploadSpinner" width="16" height="16" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#7ad7ff" style="display:none;">
|
| 111 |
+
<g fill="none" fill-rule="evenodd">
|
| 112 |
+
<g transform="translate(1 1)" stroke-width="2">
|
| 113 |
+
<circle stroke-opacity=".5" cx="18" cy="18" r="18"/>
|
| 114 |
+
<path d="M36 18c0-9.94-8.06-18-18-18">
|
| 115 |
+
<animateTransform attributeName="transform" type="rotate" from="0 18 18" to="360 18 18" dur="1s" repeatCount="indefinite"/>
|
| 116 |
+
</path>
|
| 117 |
+
</g>
|
| 118 |
+
</g>
|
| 119 |
+
</svg>
|
| 120 |
+
<span class="cm-dot"></span>
|
| 121 |
+
<span id="cmToastMsg">Loading file… please wait</span>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
<!-- S‑Curve Loader Overlay -->
|
| 125 |
+
<div id="cmLoader" class="cm-loader" style="display:none;">
|
| 126 |
+
<div class="cm-loader-card">
|
| 127 |
+
<svg class="cm-hero" viewBox="0 0 600 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
| 128 |
+
<!-- faint grid -->
|
| 129 |
+
<g opacity=".12">
|
| 130 |
+
<path d="M0 140 H600" stroke="#9fb4df"/>
|
| 131 |
+
<path d="M0 100 H600" stroke="#9fb4df"/>
|
| 132 |
+
<path d="M0 60 H600" stroke="#9fb4df"/>
|
| 133 |
+
<path d="M0 20 H600" stroke="#9fb4df"/>
|
| 134 |
+
</g>
|
| 135 |
+
<!-- S-curve path (animated stroke-dashoffset draw) -->
|
| 136 |
+
<path id="cmCurve" d="M10,140 C120,120 180,40 260,60 C330,77 360,95 410,80 C480,60 500,20 590,20"
|
| 137 |
+
fill="none" stroke="#7ad7ff" stroke-width="4" stroke-linecap="round" />
|
| 138 |
+
<!-- running dot -->
|
| 139 |
+
<circle id="cmDot" r="5" fill="#22e1e8"/>
|
| 140 |
+
</svg>
|
| 141 |
+
<div class="cm-loader-body">
|
| 142 |
+
<div class="cm-title">Loading data</div>
|
| 143 |
+
<div class="cm-sub" id="cmStep">Reading file…</div>
|
| 144 |
+
<div class="cm-progress">
|
| 145 |
+
<div id="cmBar" class="cm-bar"></div>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="cm-pct" id="cmPct">0%</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<main class="wrap p-6 space-y-6">
|
| 153 |
+
<!-- OVERVIEW -->
|
| 154 |
+
<section>
|
| 155 |
+
<div class="section"><h2>Overview</h2><div class="hint">Rolling burn + quick forecast</div></div>
|
| 156 |
+
<div class="kpi-grid">
|
| 157 |
+
<div class="card"><div class="kpi"><div class="label">Total Cost</div><div id="k_total" class="value">—</div></div></div>
|
| 158 |
+
<div class="card"><div class="kpi"><div class="label">Date Range</div><div id="k_window" class="value">—</div></div></div>
|
| 159 |
+
<div class="card"><div class="kpi"><div class="label">Avg Daily Burn</div><div id="k_avg" class="value">—</div></div></div>
|
| 160 |
+
<div class="card"><div class="kpi"><div class="label">Forecast to Target</div>
|
| 161 |
+
<input id="targetDate" type="date" class="btn w-full my-1"/>
|
| 162 |
+
<div id="k_forecast" class="value small">—</div>
|
| 163 |
+
<div id="k_forecast_formula" class="note">—</div>
|
| 164 |
+
</div></div>
|
| 165 |
+
</div>
|
| 166 |
+
</section>
|
| 167 |
+
|
| 168 |
+
<!-- FILTERS -->
|
| 169 |
+
<section>
|
| 170 |
+
<div class="section"><h2>Filters</h2><div class="hint">Company search, MAFE/AFE, WBS & date slider</div></div>
|
| 171 |
+
<div class="card">
|
| 172 |
+
<div class="grid lg:grid-cols-3 gap-5">
|
| 173 |
+
<!-- Company -->
|
| 174 |
+
<div class="lg:col-span-2">
|
| 175 |
+
<div class="hint mb-1">Company</div>
|
| 176 |
+
<div class="card" style="padding:.6rem">
|
| 177 |
+
<div class="flex gap-2 mb-2">
|
| 178 |
+
<input id="search" class="btn w-full" placeholder="Search companies…"/>
|
| 179 |
+
<button id="selectAll" class="btn">Select all</button>
|
| 180 |
+
<button id="clearAll" class="btn">Clear</button>
|
| 181 |
+
</div>
|
| 182 |
+
<div id="chips" class="flex flex-wrap gap-2 mb-2"></div>
|
| 183 |
+
<div id="list" class="border border-[var(--border)] rounded-lg p-2 max-h-56 overflow-auto bg-[#0c1730]"></div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
<!-- Date range -->
|
| 187 |
+
<div>
|
| 188 |
+
<div class="hint mb-1">Date Range</div>
|
| 189 |
+
<div class="card">
|
| 190 |
+
<div class="flex justify-between text-xs text-[var(--muted)] mono mb-1">
|
| 191 |
+
<span id="rangeMinBadge">—</span><span id="rangeMaxBadge">—</span>
|
| 192 |
+
</div>
|
| 193 |
+
<div class="relative">
|
| 194 |
+
<div class="track"><div class="fill" id="rangeFill"></div></div>
|
| 195 |
+
<input type="range" min="0" max="100" value="0" id="rangeMin" class="range">
|
| 196 |
+
<input type="range" min="0" max="100" value="100" id="rangeMax" class="range">
|
| 197 |
+
</div>
|
| 198 |
+
<div class="mt-2 flex flex-wrap gap-2 text-sm">
|
| 199 |
+
<button class="btn quick" data-last="5">Last 5 Days</button>
|
| 200 |
+
<button class="btn quick" data-last="7">Last 7 Days</button>
|
| 201 |
+
<button class="btn quick" data-last="10">Last 10 Days</button>
|
| 202 |
+
<button class="btn quick" data-last="14">Last 14 Days</button>
|
| 203 |
+
<button class="btn quick" id="allData">All Data</button>
|
| 204 |
+
</div>
|
| 205 |
+
<div class="hint mt-4 mb-1">MAFE vs AFE</div>
|
| 206 |
+
<select id="mafeFilter" class="btn w-full">
|
| 207 |
+
<option value="ALL">All</option>
|
| 208 |
+
<option value="AFE">AFE</option>
|
| 209 |
+
<option value="MAFE">MAFE</option>
|
| 210 |
+
</select>
|
| 211 |
+
|
| 212 |
+
<div class="hint mt-4 mb-1">Module</div>
|
| 213 |
+
<select id="moduleFilter" class="btn w-full">
|
| 214 |
+
<option value="ALL">All</option>
|
| 215 |
+
</select>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</section>
|
| 221 |
+
|
| 222 |
+
<!-- BREAKDOWN -->
|
| 223 |
+
<section>
|
| 224 |
+
<div class="section"><h2>Breakdown</h2><div class="hint">Hours are DFL only; costs and KPIs are totals</div></div>
|
| 225 |
+
<div class="grid-fit">
|
| 226 |
+
<div class="card">
|
| 227 |
+
<h3 class="font-semibold mb-2">Hours</h3>
|
| 228 |
+
<div class="grid-fit">
|
| 229 |
+
<div class="badge"><span class="dot"></span><span class="label">ST Hours</span><span id="h_st" class="val mono ml-auto">—</span></div>
|
| 230 |
+
<div class="badge"><span class="dot" style="background:var(--blue)"></span><span class="label">OT Hours</span><span id="h_ot" class="val mono ml-auto">—</span></div>
|
| 231 |
+
<div class="badge"><span class="dot" style="background:var(--green)"></span><span class="label">DT Hours</span><span id="h_dt" class="val mono ml-auto">—</span></div>
|
| 232 |
+
<div class="badge"><span class="dot" style="background:var(--pink)"></span><span class="label">Total Hours</span><span id="h_total" class="val mono ml-auto">—</span></div>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
<div class="card">
|
| 236 |
+
<h3 class="font-semibold mb-2">Cost</h3>
|
| 237 |
+
<div class="grid-fit">
|
| 238 |
+
<div class="badge"><span class="dot"></span><span class="label">ST TOT</span><span id="c_st" class="val mono ml-auto">—</span></div>
|
| 239 |
+
<div class="badge"><span class="dot" style="background:var(--blue)"></span><span class="label">OT TOT</span><span id="c_ot" class="val mono ml-auto">—</span></div>
|
| 240 |
+
<div class="badge"><span class="dot" style="background:var(--green)"></span><span class="label">DT TOT</span><span id="c_dt" class="val mono ml-auto">—</span></div>
|
| 241 |
+
<div class="badge"><span class="dot" style="background:#f6a3a3"></span><span class="label">TOTALS</span><span id="c_tot" class="val mono ml-auto">—</span></div>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
</section>
|
| 246 |
+
|
| 247 |
+
<!-- TRENDS -->
|
| 248 |
+
<section>
|
| 249 |
+
<div class="section"><h2>Trends</h2><div class="hint">Daily & Cumulative; Top spenders; Pies below</div></div>
|
| 250 |
+
|
| 251 |
+
<!-- Full-width Daily -->
|
| 252 |
+
<div class="card mb-4">
|
| 253 |
+
<h3 class="font-semibold mb-2">Daily Cost (bars) + Cumulative (line)</h3>
|
| 254 |
+
<div class="chartbox" style="height:440px"><canvas id="chartDaily"></canvas></div>
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
<!-- Full-width Cumulative -->
|
| 258 |
+
<div class="card mb-6">
|
| 259 |
+
<h3 class="font-semibold mb-2">Cumulative Cost</h3>
|
| 260 |
+
<div class="chartbox" style="height:440px"><canvas id="chartCum"></canvas></div>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<!-- Contractor Compare -->
|
| 264 |
+
<div class="card mb-6">
|
| 265 |
+
<div class="flex items-center justify-between mb-2">
|
| 266 |
+
<h3 class="font-semibold">Contractor Compare</h3>
|
| 267 |
+
<span class="hint">Uses current filters and date window. If none selected, shows top 5.</span>
|
| 268 |
+
</div>
|
| 269 |
+
<div class="chartbox" style="height:420px"><canvas id="chartCompare"></canvas></div>
|
| 270 |
+
<div id="compareLegend" class="compare-legend-bottom"></div>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
<!-- Top 10 -->
|
| 274 |
+
<div class="grid md:grid-cols-2 gap-4">
|
| 275 |
+
<div class="card">
|
| 276 |
+
<div class="flex items-center justify-between mb-2">
|
| 277 |
+
<h3 class="font-semibold">Top 10 Companies (Chart)</h3>
|
| 278 |
+
<span class="hint">Reflects current filters</span>
|
| 279 |
+
</div>
|
| 280 |
+
<div class="chartbox" style="height:360px"><canvas id="chartTop"></canvas></div>
|
| 281 |
+
</div>
|
| 282 |
+
<div class="card"><h3 class="font-semibold mb-2">Top 10 Companies (List)</h3><ol id="topList" class="space-y-2 text-sm"></ol></div>
|
| 283 |
+
</div>
|
| 284 |
+
<!-- MAFE vs AFE Detailed Tables -->
|
| 285 |
+
<section class="mt-8">
|
| 286 |
+
<div class="section"><h2>MAFE vs AFE — Last 10 Days</h2><div class="hint">Cost and hours breakdown</div></div>
|
| 287 |
+
<div class="grid md:grid-cols-2 gap-4">
|
| 288 |
+
<div class="card">
|
| 289 |
+
<h3 class="font-semibold mb-2">Cost Comparison</h3>
|
| 290 |
+
<div class="overflow-auto max-h-[500px]">
|
| 291 |
+
<table id="afeMafeCost" class="w-full border-collapse">
|
| 292 |
+
<thead>
|
| 293 |
+
<tr class="border-b border-[var(--border)]">
|
| 294 |
+
<th class="text-left p-2">Date</th>
|
| 295 |
+
<th class="text-right p-2">MAFE</th>
|
| 296 |
+
<th class="text-right p-2">Capital</th>
|
| 297 |
+
<th class="text-right p-2">Total</th>
|
| 298 |
+
</tr>
|
| 299 |
+
</thead>
|
| 300 |
+
<tbody class="divide-y divide-[var(--border)]"></tbody>
|
| 301 |
+
</table>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
<div class="card">
|
| 305 |
+
<h3 class="font-semibold mb-2">Hours Comparison</h3>
|
| 306 |
+
<div class="overflow-auto max-h-[500px]">
|
| 307 |
+
<table id="afeMafeHours" class="w-full border-collapse">
|
| 308 |
+
<thead>
|
| 309 |
+
<tr class="border-b border-[var(--border)]">
|
| 310 |
+
<th class="text-left p-2">Date</th>
|
| 311 |
+
<th class="text-right p-2">MAFE</th>
|
| 312 |
+
<th class="text-right p-2">AFE</th>
|
| 313 |
+
<th class="text-right p-2">Total</th>
|
| 314 |
+
</tr>
|
| 315 |
+
</thead>
|
| 316 |
+
<tbody class="divide-y divide-[var(--border)]"></tbody>
|
| 317 |
+
</table>
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
</section>
|
| 322 |
+
|
| 323 |
+
<!-- Pies underneath with spacing -->
|
| 324 |
+
<div class="grid md:grid-cols-2 gap-4 mt-8">
|
| 325 |
+
<div class="card">
|
| 326 |
+
<h3 class="font-semibold mb-2">MAFE vs AFE</h3>
|
| 327 |
+
<div class="chartbox" style="height:320px"><canvas id="chartMafe"></canvas></div>
|
| 328 |
+
</div>
|
| 329 |
+
<div class="card">
|
| 330 |
+
<h3 class="font-semibold mb-2">Pie by WBS Element (Top 10)</h3>
|
| 331 |
+
<div class="chartbox" style="height:320px"><canvas id="chartWbs"></canvas></div>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
|
| 335 |
+
<!-- Cost by Module with additional spacing -->
|
| 336 |
+
<section class="mt-8">
|
| 337 |
+
<div class="card">
|
| 338 |
+
<div class="flex items-center justify-between mb-2">
|
| 339 |
+
<h3 class="font-semibold">Cost by Module</h3>
|
| 340 |
+
<span class="hint">Uses current filters and date window.</span>
|
| 341 |
+
</div>
|
| 342 |
+
<div class="grid md:grid-cols-2 gap-4 items-start">
|
| 343 |
+
<div class="chartbox" style="height:260px"><canvas id="chartModule"></canvas></div>
|
| 344 |
+
<div>
|
| 345 |
+
<div class="flex items-center justify-between mb-2">
|
| 346 |
+
<div class="hint">Module breakdown</div>
|
| 347 |
+
<div class="flex gap-2">
|
| 348 |
+
<button id="moduleShowDollar" class="btn">Show $</button>
|
| 349 |
+
<button id="moduleShowPercent" class="btn">Show %</button>
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
<div class="overflow-auto max-h-[260px]">
|
| 353 |
+
<table id="moduleTable" class="w-full border-collapse">
|
| 354 |
+
<thead>
|
| 355 |
+
<tr class="border-b border-[var(--border)]">
|
| 356 |
+
<th class="text-left p-2">Module</th>
|
| 357 |
+
<th class="text-right p-2">Cost</th>
|
| 358 |
+
<th class="text-right p-2">% of Total</th>
|
| 359 |
+
<th class="text-right p-2">Rank</th>
|
| 360 |
+
</tr>
|
| 361 |
+
</thead>
|
| 362 |
+
<tbody class="divide-y divide-[var(--border)]"></tbody>
|
| 363 |
+
</table>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
</section>
|
| 369 |
+
</section>
|
| 370 |
+
|
| 371 |
+
<!-- ===== Monte Carlo — Cost Projection ===== -->
|
| 372 |
+
<section id="monte-carlo-section" class="mt-6">
|
| 373 |
+
<div class="section"><h2>Monte Carlo — Cost Projection</h2><div class="hint">Fan bands P10 / P50 / P90</div></div>
|
| 374 |
+
<div class="card">
|
| 375 |
+
<div class="flex flex-wrap items-center gap-2">
|
| 376 |
+
<span class="hint uppercase text-xs mr-1">Rate Window</span>
|
| 377 |
+
<button class="btn" id="mc_win_30" data-window="30">30d</button>
|
| 378 |
+
<button class="btn" id="mc_win_45" data-window="45">45d</button>
|
| 379 |
+
<button class="btn" id="mc_win_60" data-window="60">60d</button>
|
| 380 |
+
|
| 381 |
+
<div class="mx-2"></div>
|
| 382 |
+
|
| 383 |
+
<label class="hint">Current Burn Rate ($/day)</label>
|
| 384 |
+
<input id="mc_manualRate" type="text" inputmode="decimal" class="btn mono" placeholder="auto" style="width:140px" />
|
| 385 |
+
|
| 386 |
+
<div class="mx-2"></div>
|
| 387 |
+
|
| 388 |
+
<label class="hint">±%</label>
|
| 389 |
+
<input id="mc_uncertainty" type="range" min="0" max="50" value="20" style="accent-color:#22e1e8;width:130px">
|
| 390 |
+
<span id="mc_uncertaintyLabel" class="mono hint" style="width:36px;text-align:center">20%</span>
|
| 391 |
+
|
| 392 |
+
<div class="mx-2"></div>
|
| 393 |
+
|
| 394 |
+
<label class="hint">Horizon</label>
|
| 395 |
+
<input id="mc_horizon" type="number" min="1" max="365" value="60" class="btn mono" style="width:80px">
|
| 396 |
+
|
| 397 |
+
<label class="hint ml-2">Iters</label>
|
| 398 |
+
<input id="mc_iters" type="number" min="100" max="20000" step="100" value="5000" class="btn mono" style="width:90px">
|
| 399 |
+
|
| 400 |
+
<div class="ml-auto flex gap-2">
|
| 401 |
+
<button id="mc_runBtn" class="btn">Run</button>
|
| 402 |
+
<button id="mc_exportBtn" class="btn">Export CSV</button>
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
|
| 406 |
+
<div class="hint mt-2">
|
| 407 |
+
💡 Tip: Rate window averages recent daily burn. Or type a Current Burn Rate. Adjust ±%, horizon, and iters, then click <b>Run</b>. Export CSV to download results.
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
<div class="grid lg:grid-cols-12 gap-3 mt-3">
|
| 411 |
+
<div class="card lg:col-span-8" style="height:460px">
|
| 412 |
+
<canvas id="mc_fanChart" style="width:100%;height:100%"></canvas>
|
| 413 |
+
</div>
|
| 414 |
+
|
| 415 |
+
<div class="lg:col-span-4 grid gap-3">
|
| 416 |
+
<div class="card">
|
| 417 |
+
<div class="kpi-title hint">Current Total</div>
|
| 418 |
+
<div id="mc_kpiCurrent" class="value mono">$—</div>
|
| 419 |
+
</div>
|
| 420 |
+
<div class="card">
|
| 421 |
+
<div class="kpi-title hint">Projected Total (P10)</div>
|
| 422 |
+
<div id="mc_kpiP10" class="value mono" style="color:#22e1e8">$—</div>
|
| 423 |
+
</div>
|
| 424 |
+
<div class="card">
|
| 425 |
+
<div class="kpi-title hint">Projected Total (P50)</div>
|
| 426 |
+
<div id="mc_kpiP50" class="value mono" style="color:#ffc455">$—</div>
|
| 427 |
+
</div>
|
| 428 |
+
<div class="card">
|
| 429 |
+
<div class="kpi-title hint">Projected Total (P90)</div>
|
| 430 |
+
<div id="mc_kpiP90" class="value mono" style="color:#f08ac3">$—</div>
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
</div>
|
| 434 |
+
</div>
|
| 435 |
+
</section>
|
| 436 |
+
</main>
|
| 437 |
+
|
| 438 |
+
<script>
|
| 439 |
+
const $=(id)=>document.getElementById(id);
|
| 440 |
+
const fmt=(n)=> n?.toLocaleString(undefined,{style:'currency',currency:'USD',maximumFractionDigits:0}) ?? '—';
|
| 441 |
+
const fmt0=(n)=> isNaN(n)?'—':n.toLocaleString(undefined,{maximumFractionDigits:0});
|
| 442 |
+
const parseDate=(v)=>{
|
| 443 |
+
if(v==null||v==='')return null;
|
| 444 |
+
if(typeof v==='number'){const s=String(v);if(s.length===8)return new Date(+s.slice(0,4),+s.slice(4,6)-1,+s.slice(6,8));return new Date((v-25569)*86400*1000);}
|
| 445 |
+
if(typeof v==='string'){const s=v.trim();if(/^\\d{8}$/.test(s))return new Date(+s.slice(0,4),+s.slice(4,6)-1,+s.slice(6,8));const d=new Date(s);return isNaN(d)?null:d;}
|
| 446 |
+
const d=new Date(v);return isNaN(d)?null:d;
|
| 447 |
+
};
|
| 448 |
+
const parseNum=(v)=>{if(v==null||v==='')return NaN;if(typeof v==='number')return v; let s=v.toString().trim(); const neg=/^\\(.*\\)$/.test(s); s=s.replace(/^\\((.*)\\)$/,'$1').replace(/[$,]/g,''); const n=parseFloat(s); return isNaN(n)?NaN:(neg?-n:n);};
|
| 449 |
+
const norm=(s)=> (s??'').toString().trim().toLowerCase();
|
| 450 |
+
const isDFL = v => String(v||'').toUpperCase().includes('DFL');
|
| 451 |
+
|
| 452 |
+
let RAW=[], FILTERED=[], DAILY=[], CUM=[], TOP=[], charts={};
|
| 453 |
+
let ALL_DATES=[]; let COMPANIES=[], SELECTED=new Set(); let MAFE='ALL'; let MAFE_EXACT='ALL'; let MODULE='ALL';
|
| 454 |
+
|
| 455 |
+
// Define a harmonious color palette that fits the current theme
|
| 456 |
+
const chartColors = [
|
| 457 |
+
'#22e1e8', // teal from gradient
|
| 458 |
+
'#7ad7ff', // blue from gradient
|
| 459 |
+
'#a87fff', // purple accent
|
| 460 |
+
'#f5a524', // amber/yellow
|
| 461 |
+
'#86efac', // green accent
|
| 462 |
+
'#f08ac3', // pink accent
|
| 463 |
+
'#ffa055', // orange accent
|
| 464 |
+
'#35b5ff', // lighter blue
|
| 465 |
+
'#1dc6c2', // slightly deeper teal
|
| 466 |
+
'#c28bff' // lighter purple
|
| 467 |
+
];
|
| 468 |
+
|
| 469 |
+
function mapRow(r){
|
| 470 |
+
const k=Object.fromEntries(Object.keys(r).map(x=>[norm(x),r[x]]));
|
| 471 |
+
return {
|
| 472 |
+
module:(k['module']||k['module name']||k['mod']||k['mdl']||'').toString().trim(),
|
| 473 |
+
ts:parseDate(k['ts date']||k['ts_date']||k['tsdate']||k['date']||k['ts']),
|
| 474 |
+
total:parseNum(k['grand total']||k['grandtotal']||k['total']||k['amount']||k['totals']),
|
| 475 |
+
company:(k['company']||k['vendor']||k['contractor']||'').toString().trim(),
|
| 476 |
+
staff:(k['staff']||k['labor type']||k['category']||k['staff type']||'').toString().trim(),
|
| 477 |
+
st_hours:parseNum(k['st hours']||k['sthours']||k['s thours']),
|
| 478 |
+
ot_hours:parseNum(k['ot hours']||k['othours']),
|
| 479 |
+
dt_hours:parseNum(k['dt hours']||k['dthours']),
|
| 480 |
+
st_tot:parseNum(k['st tot']||k['st total']||k['st cost']),
|
| 481 |
+
ot_tot:parseNum(k['ot tot']||k['ot total']||k['ot cost']),
|
| 482 |
+
dt_tot:parseNum(k['dt tot']||k['dt total']||k['dt cost']),
|
| 483 |
+
mafe_raw:(k['mafe vs afe']||k['mafe_vs_afe']||k['mafe/afe']||'').toString().trim(),
|
| 484 |
+
mafe:(()=>{
|
| 485 |
+
const raw=(k['mafe vs afe']||k['mafe_vs_afe']||k['mafe/afe']||'').toString().trim();
|
| 486 |
+
const u=raw.toUpperCase();
|
| 487 |
+
if(u.includes('MAFE')) return 'MAFE';
|
| 488 |
+
if(u.includes('AFE')) return 'AFE';
|
| 489 |
+
return u;
|
| 490 |
+
})(),
|
| 491 |
+
wbs:(k['wbs element']||k['wbs']||'').toString().trim()
|
| 492 |
+
};
|
| 493 |
+
}
|
| 494 |
+
const uniqueSorted = arr => Array.from(new Set(arr.filter(Boolean))).sort((a,b)=>a>b?1:-1);
|
| 495 |
+
|
| 496 |
+
// Company picker
|
| 497 |
+
function buildPicker(){
|
| 498 |
+
COMPANIES = uniqueSorted(RAW.map(r=>r.company));
|
| 499 |
+
const list=$('list'), chips=$('chips');
|
| 500 |
+
function renderList(){
|
| 501 |
+
list.innerHTML='';
|
| 502 |
+
const q=($('search').value||'').toLowerCase();
|
| 503 |
+
COMPANIES.filter(c=>!q||c.toLowerCase().includes(q)).forEach(c=>{
|
| 504 |
+
const row=document.createElement('label'); row.className='flex items-center gap-2 py-1';
|
| 505 |
+
row.innerHTML=`<input type="checkbox" ${SELECTED.has(c)?'checked':''} /><span>${c||'—'}</span>`;
|
| 506 |
+
const cb=row.querySelector('input');
|
| 507 |
+
cb.addEventListener('change',(e)=>{
|
| 508 |
+
if(e.target.checked) SELECTED.add(c); else SELECTED.delete(c);
|
| 509 |
+
renderChips();
|
| 510 |
+
applyFilters(); // Update filters immediately when selection changes
|
| 511 |
+
});
|
| 512 |
+
list.appendChild(row);
|
| 513 |
+
});
|
| 514 |
+
}
|
| 515 |
+
function renderChips(){
|
| 516 |
+
chips.innerHTML='';
|
| 517 |
+
if(!SELECTED.size){ chips.innerHTML='<span class="hint">No companies selected (all included)</span>'; return; }
|
| 518 |
+
SELECTED.forEach(c=>{
|
| 519 |
+
const s=document.createElement('span'); s.className='px-2 py-1 rounded-full border border-[var(--border)] bg-[#0b1830] text-sm'; s.textContent=c;
|
| 520 |
+
const x=document.createElement('span'); x.textContent=' ×'; x.style.cursor='pointer'; x.style.opacity='.7';
|
| 521 |
+
x.onclick=()=>{ SELECTED.delete(c); renderList(); renderChips(); applyFilters(); };
|
| 522 |
+
s.appendChild(x); chips.appendChild(s);
|
| 523 |
+
});
|
| 524 |
+
}
|
| 525 |
+
$('selectAll').onclick=()=>{
|
| 526 |
+
COMPANIES.forEach(c=>SELECTED.add(c));
|
| 527 |
+
renderList();
|
| 528 |
+
renderChips();
|
| 529 |
+
applyFilters();
|
| 530 |
+
};
|
| 531 |
+
$('clearAll').onclick=()=>{
|
| 532 |
+
SELECTED.clear();
|
| 533 |
+
renderList();
|
| 534 |
+
renderChips();
|
| 535 |
+
applyFilters();
|
| 536 |
+
};
|
| 537 |
+
$('search').oninput=renderList;
|
| 538 |
+
renderList(); renderChips();
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
// Range slider
|
| 542 |
+
function seedRange(){
|
| 543 |
+
ALL_DATES = uniqueSorted(RAW.map(r=>r.ts&&r.ts.toISOString().slice(0,10)));
|
| 544 |
+
const max=Math.max(ALL_DATES.length-1,0);
|
| 545 |
+
$('rangeMin').min=0; $('rangeMax').min=0;
|
| 546 |
+
$('rangeMin').max=max; $('rangeMax').max=max;
|
| 547 |
+
$('rangeMin').value=0; $('rangeMax').value=max;
|
| 548 |
+
updateRangeUI();
|
| 549 |
+
}
|
| 550 |
+
function updateRangeUI(){
|
| 551 |
+
const lo=Math.min(+$('rangeMin').value,+$('rangeMax').value), hi=Math.max(+$('rangeMin').value,+$('rangeMax').value);
|
| 552 |
+
$('rangeMin').value=lo; $('rangeMax').value=hi;
|
| 553 |
+
$('rangeMinBadge').textContent=ALL_DATES[lo]||'—';
|
| 554 |
+
$('rangeMaxBadge').textContent=ALL_DATES[hi]||'—';
|
| 555 |
+
const a=(ALL_DATES.length<=1)?0:lo/(ALL_DATES.length-1)*100;
|
| 556 |
+
const b=(ALL_DATES.length<=1)?100:hi/(ALL_DATES.length-1)*100;
|
| 557 |
+
const f=$('rangeFill'); f.style.left=a+'%'; f.style.right=(100-b)+'%';
|
| 558 |
+
applyFilters();
|
| 559 |
+
}
|
| 560 |
+
function setLast(n){ if(!ALL_DATES.length) return; $('rangeMax').value=ALL_DATES.length-1; $('rangeMin').value=Math.max(0,ALL_DATES.length-n); updateRangeUI(); }
|
| 561 |
+
function setAll(){ if(!ALL_DATES.length) return; $('rangeMin').value=0; $('rangeMax').value=ALL_DATES.length-1; updateRangeUI(); }
|
| 562 |
+
|
| 563 |
+
// Aggregation
|
| 564 |
+
function applyFilters(){
|
| 565 |
+
if(!RAW.length) return;
|
| 566 |
+
const lo=+$('rangeMin').value, hi=+$('rangeMax').value;
|
| 567 |
+
const loDate=ALL_DATES[Math.min(lo,hi)]||null, hiDate=ALL_DATES[Math.max(lo,hi)]||null;
|
| 568 |
+
const sel=Array.from(SELECTED.values());
|
| 569 |
+
FILTERED = RAW.filter(r=>{
|
| 570 |
+
const d=r.ts&&r.ts.toISOString().slice(0,10);
|
| 571 |
+
if(loDate && d<loDate) return false;
|
| 572 |
+
if(hiDate && d>hiDate) return false;
|
| 573 |
+
// Company filter - check if selected companies exist and match
|
| 574 |
+
if(sel.length > 0) {
|
| 575 |
+
const companyMatch = sel.some(selectedCompany => {
|
| 576 |
+
return (r.company || '').trim().toLowerCase() === selectedCompany.trim().toLowerCase();
|
| 577 |
+
});
|
| 578 |
+
if(!companyMatch) return false;
|
| 579 |
+
}
|
| 580 |
+
if(MAFE_EXACT!=='ALL' && (r.mafe_raw||'')!==MAFE_EXACT) return false;
|
| 581 |
+
if(MAFE!=='ALL' && (r.mafe||'').toUpperCase()!==MAFE) return false;
|
| 582 |
+
if(MODULE!=='ALL' && String(r.module||'').toUpperCase()!==String(MODULE).toUpperCase()) return false;
|
| 583 |
+
return true;
|
| 584 |
+
});
|
| 585 |
+
aggregateAndRender();
|
| 586 |
+
}
|
| 587 |
+
function aggregateAndRender(){
|
| 588 |
+
const by=new Map();
|
| 589 |
+
FILTERED.forEach(r=>{const d=r.ts&&r.ts.toISOString().slice(0,10); if(!d)return; by.set(d,(by.get(d)||0)+(r.total||0));});
|
| 590 |
+
DAILY=Array.from(by.entries()).sort((a,b)=>a[0].localeCompare(b[0])).map(([d,v])=>({d,v}));
|
| 591 |
+
let run=0; CUM=DAILY.map(x=>{run+=x.v; return {d:x.d,v:run};});
|
| 592 |
+
|
| 593 |
+
const comp = new Map();
|
| 594 |
+
FILTERED.forEach(r=>{ const c = r.company || '—'; comp.set(c, (comp.get(c)||0) + (r.total||0)); });
|
| 595 |
+
TOP = Array.from(comp.entries()).sort((a,b)=>b[1]-a[1]).slice(0,10);
|
| 596 |
+
|
| 597 |
+
const tot=CUM.length?CUM[CUM.length-1].v:0, avg=DAILY.length?tot/DAILY.length:0;
|
| 598 |
+
$('k_total').textContent=fmt(tot); $('k_avg').textContent=fmt(avg);
|
| 599 |
+
$('k_window').textContent=DAILY.length ? (DAILY[0].d+' → '+DAILY[DAILY.length-1].d) : '—';
|
| 600 |
+
const sum=(arr,k)=>arr.reduce((a,b)=>a+(+b[k]||0),0);
|
| 601 |
+
const DFILTERED = FILTERED.filter(x => isDFL(x.staff));
|
| 602 |
+
const hst=sum(DFILTERED,'st_hours'), hot=sum(DFILTERED,'ot_hours'), hdt=sum(DFILTERED,'dt_hours');
|
| 603 |
+
$('h_st').textContent=fmt0(hst); $('h_ot').textContent=fmt0(hot); $('h_dt').textContent=fmt0(hdt); $('h_total').textContent=fmt0(hst+hot+hdt);
|
| 604 |
+
const cst=sum(FILTERED,'st_tot'), cot=sum(FILTERED,'ot_tot'), cdt=sum(FILTERED,'dt_tot');
|
| 605 |
+
$('c_st').textContent=fmt0(cst); $('c_ot').textContent=fmt0(cot); $('c_dt').textContent=fmt0(cdt);
|
| 606 |
+
$('c_tot').textContent=fmt0(cst+cot+cdt);
|
| 607 |
+
drawCharts(); drawCompare(); drawTopList(TOP);
|
| 608 |
+
drawMafePie(); drawWbsPie(); drawModulePie();
|
| 609 |
+
updateMafeAfeTables(); // Add this to ensure tables update
|
| 610 |
+
calcForecast();
|
| 611 |
+
}
|
| 612 |
+
function updateMafeAfeTables() {
|
| 613 |
+
if (!FILTERED.length) return;
|
| 614 |
+
|
| 615 |
+
// Get last 10 unique dates from filtered data
|
| 616 |
+
const dates = Array.from(new Set(FILTERED.map(r => r.ts && r.ts.toISOString().slice(0,10))))
|
| 617 |
+
.filter(Boolean)
|
| 618 |
+
.sort((a,b) => new Date(a) - new Date(b))
|
| 619 |
+
.slice(-10);
|
| 620 |
+
|
| 621 |
+
// Group by date and MAFE/AFE
|
| 622 |
+
const costByDate = {};
|
| 623 |
+
const hoursByDate = {};
|
| 624 |
+
|
| 625 |
+
// Initialize all dates with zero values
|
| 626 |
+
dates.forEach(d => {
|
| 627 |
+
costByDate[d] = { MAFE: 0, AFE: 0 };
|
| 628 |
+
hoursByDate[d] = {
|
| 629 |
+
MAFE: { st: 0, ot: 0, dt: 0, total: 0 },
|
| 630 |
+
AFE: { st: 0, ot: 0, dt: 0, total: 0 }
|
| 631 |
+
};
|
| 632 |
+
});
|
| 633 |
+
|
| 634 |
+
// Process filtered data
|
| 635 |
+
FILTERED.forEach(r => {
|
| 636 |
+
const d = r.ts && r.ts.toISOString().slice(0,10);
|
| 637 |
+
if (!d || !dates.includes(d)) return;
|
| 638 |
+
|
| 639 |
+
const isMAFE = (r.mafe || '').toUpperCase().includes('MAFE');
|
| 640 |
+
const key = isMAFE ? 'MAFE' : 'AFE';
|
| 641 |
+
|
| 642 |
+
// Sum costs
|
| 643 |
+
costByDate[d][key] += r.total || 0;
|
| 644 |
+
|
| 645 |
+
// Sum hours (only DFL)
|
| 646 |
+
if (isDFL(r.staff)) {
|
| 647 |
+
hoursByDate[d][key].st += r.st_hours || 0;
|
| 648 |
+
hoursByDate[d][key].ot += r.ot_hours || 0;
|
| 649 |
+
hoursByDate[d][key].dt += r.dt_hours || 0;
|
| 650 |
+
hoursByDate[d][key].total += (r.st_hours || 0) + (r.ot_hours || 0) + (r.dt_hours || 0);
|
| 651 |
+
}
|
| 652 |
+
});
|
| 653 |
+
|
| 654 |
+
// Render cost table
|
| 655 |
+
const costTable = document.getElementById('afeMafeCost').querySelector('tbody');
|
| 656 |
+
costTable.innerHTML = dates.map(d => {
|
| 657 |
+
const mafe = costByDate[d].MAFE;
|
| 658 |
+
const afe = costByDate[d].AFE;
|
| 659 |
+
const total = mafe + afe;
|
| 660 |
+
return `
|
| 661 |
+
<tr class="hover:bg-[var(--panel)]">
|
| 662 |
+
<td class="p-2">${d}</td>
|
| 663 |
+
<td class="p-2 text-right mono">${fmt(mafe)}</td>
|
| 664 |
+
<td class="p-2 text-right mono">${fmt(afe)} </td>
|
| 665 |
+
<td class="p-2 text-right mono">${fmt(total)}</td>
|
| 666 |
+
</tr>
|
| 667 |
+
`;
|
| 668 |
+
}).join('');
|
| 669 |
+
|
| 670 |
+
// Render hours table
|
| 671 |
+
const hoursTable = document.getElementById('afeMafeHours').querySelector('tbody');
|
| 672 |
+
hoursTable.innerHTML = dates.map(d => {
|
| 673 |
+
const mafe = hoursByDate[d].MAFE.total;
|
| 674 |
+
const afe = hoursByDate[d].AFE.total;
|
| 675 |
+
return `
|
| 676 |
+
<tr class="hover:bg-[var(--panel)]">
|
| 677 |
+
<td class="p-2">${d}</td>
|
| 678 |
+
<td class="p-2 text-right mono">${fmt0(mafe)}</td>
|
| 679 |
+
<td class="p-2 text-right mono">${fmt0(afe)}</td>
|
| 680 |
+
<td class="p-2 text-right mono">${fmt0(mafe + afe)}</td>
|
| 681 |
+
</tr>
|
| 682 |
+
`;
|
| 683 |
+
}).join('');
|
| 684 |
+
}
|
| 685 |
+
function drawTopList(arr){
|
| 686 |
+
const ol = $('topList'); ol.innerHTML='';
|
| 687 |
+
if(!arr.length){ol.innerHTML='<li class="hint">No data</li>';return;}
|
| 688 |
+
const max=Math.max(...arr.map(t=>t[1]));
|
| 689 |
+
arr.forEach(([name,val],i)=>{
|
| 690 |
+
const li=document.createElement('li');
|
| 691 |
+
li.innerHTML = `<div class="flex items-center gap-2">
|
| 692 |
+
<span class="mono text-xs" style="width:1.5rem;opacity:.7">${(i+1).toString().padStart(2,'0')}</span>
|
| 693 |
+
<div class="flex-1 min-w-0">
|
| 694 |
+
<div class="flex justify-between gap-2">
|
| 695 |
+
<span class="truncate">${name||'—'}</span>
|
| 696 |
+
<span class="mono">${fmt(val)}</span>
|
| 697 |
+
</div>
|
| 698 |
+
<div style="height:6px;background:#0f1e39;border:1px solid var(--border);border-radius:999px;margin-top:4px;position:relative">
|
| 699 |
+
<div style="position:absolute;left:0;top:0;height:6px;border-radius:999px;background:linear-gradient(90deg,var(--teal),#7ad7ff);width:${max?(val/max*100).toFixed(1):0}%"></div>
|
| 700 |
+
</div>
|
| 701 |
+
</div>
|
| 702 |
+
</div>`;
|
| 703 |
+
ol.appendChild(li);
|
| 704 |
+
});
|
| 705 |
+
}
|
| 706 |
+
function drawCompare(){
|
| 707 |
+
const ctx = document.getElementById('chartCompare'); if(!ctx) return;
|
| 708 |
+
if (charts.compare){ charts.compare.destroy(); charts.compare=null; }
|
| 709 |
+
const legend = document.getElementById('compareLegend'); if(legend) legend.innerHTML='';
|
| 710 |
+
if (!FILTERED.length){ charts.compare = new Chart(ctx,{type:'line',data:{labels:[],datasets:[]},options:{responsive:true,maintainAspectRatio:false}}); return; }
|
| 711 |
+
|
| 712 |
+
const modeBtn = document.getElementById('compareMode');
|
| 713 |
+
const mode = modeBtn?.dataset.mode || 'absolute'; // 'absolute' or 'indexed'
|
| 714 |
+
|
| 715 |
+
// Build date index for current window
|
| 716 |
+
const dates = Array.from(new Set(FILTERED.map(r=>r.ts && r.ts.toISOString().slice(0,10)))).filter(Boolean).sort();
|
| 717 |
+
const dateIdx = new Map(dates.map((d,i)=>[d,i]));
|
| 718 |
+
|
| 719 |
+
// Choose companies: selected chips, or top 5 by filtered spend
|
| 720 |
+
let chosen = Array.from(SELECTED.values());
|
| 721 |
+
if(!chosen.length){
|
| 722 |
+
const comp = new Map();
|
| 723 |
+
FILTERED.forEach(r=>{ const c=r.company||'—'; comp.set(c,(comp.get(c)||0)+(r.total||0)); });
|
| 724 |
+
chosen = Array.from(comp.entries()).sort((a,b)=>b[1]-a[1]).slice(0,5).map(x=>x[0]);
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
// Build per-day and cumulative per company
|
| 728 |
+
const daily = {}; const series = {};
|
| 729 |
+
chosen.forEach(c=>{ daily[c]=new Array(dates.length).fill(0); series[c]=new Array(dates.length).fill(0); });
|
| 730 |
+
FILTERED.forEach(r=>{
|
| 731 |
+
const c=r.company||'—'; if(!daily.hasOwnProperty(c)) return;
|
| 732 |
+
const d=r.ts && r.ts.toISOString().slice(0,10); const i=dateIdx.get(d); if(i==null) return;
|
| 733 |
+
daily[c][i] += (r.total||0);
|
| 734 |
+
});
|
| 735 |
+
Object.keys(series).forEach(c=>{
|
| 736 |
+
let run=0; series[c] = daily[c].map(v=>{ run+=v; return run; }); // cumulative
|
| 737 |
+
});
|
| 738 |
+
|
| 739 |
+
// Optional indexing
|
| 740 |
+
if(mode==='indexed'){
|
| 741 |
+
Object.keys(series).forEach(c=>{
|
| 742 |
+
const arr = series[c];
|
| 743 |
+
const base = arr.find(v=>v>0) || 0;
|
| 744 |
+
if(base>0){ series[c] = arr.map(v=> (v/base)*100 ); }
|
| 745 |
+
});
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
// datasets and legend
|
| 749 |
+
const datasets = Object.keys(series).map((c,i)=>({
|
| 750 |
+
label:c,
|
| 751 |
+
data:series[c],
|
| 752 |
+
borderColor:chartColors[i%chartColors.length],
|
| 753 |
+
tension:.25,
|
| 754 |
+
pointRadius:0
|
| 755 |
+
}));
|
| 756 |
+
|
| 757 |
+
// Custom legend pills with latest values
|
| 758 |
+
if(legend){
|
| 759 |
+
Object.keys(series).forEach((c,i)=>{
|
| 760 |
+
const latest = series[c].length ? series[c][series[c].length-1] : 0;
|
| 761 |
+
const pill = document.createElement('div'); pill.className='compare-pill';
|
| 762 |
+
pill.innerHTML = `<span class="compare-dot" style="background:${chartColors[i%chartColors.length]}"></span><span>${c}</span><span class="val">${mode==='indexed'? latest.toFixed(0): (latest||0).toLocaleString()}</span>`;
|
| 763 |
+
pill.addEventListener('click',()=>{ // toggle dataset visibility
|
| 764 |
+
const ds = charts.compare.data.datasets[i]; ds.hidden = !ds.hidden; charts.compare.update();
|
| 765 |
+
pill.style.opacity = ds.hidden? .45 : 1;
|
| 766 |
+
});
|
| 767 |
+
legend.appendChild(pill);
|
| 768 |
+
});
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
charts.compare = new Chart(ctx,{
|
| 772 |
+
type:'line',
|
| 773 |
+
data:{ labels:dates, datasets },
|
| 774 |
+
options:{
|
| 775 |
+
responsive:true,
|
| 776 |
+
maintainAspectRatio:false,
|
| 777 |
+
plugins:{ legend:{display:false} },
|
| 778 |
+
interaction:{mode:'nearest',intersect:false},
|
| 779 |
+
scales:{
|
| 780 |
+
y:{
|
| 781 |
+
grid: { color: 'rgba(255,255,255,.05)' },
|
| 782 |
+
ticks: {
|
| 783 |
+
callback:(v)=> mode==='indexed'? (v|0) : (typeof v==='number'? v.toLocaleString():v)
|
| 784 |
+
}
|
| 785 |
+
},
|
| 786 |
+
x: {
|
| 787 |
+
grid: { color: 'rgba(255,255,255,.05)' }
|
| 788 |
+
}
|
| 789 |
+
}
|
| 790 |
+
}
|
| 791 |
+
});
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
function drawCharts(){
|
| 796 |
+
const labels=DAILY.map(x=>x.d);
|
| 797 |
+
['daily','cum','top'].forEach(k=>{ if(charts[k]){charts[k].destroy(); charts[k]=null;} });
|
| 798 |
+
// Combined chart: Daily bars (left y) + Cumulative line (right y)
|
| 799 |
+
charts.daily = new Chart(document.getElementById('chartDaily'), {
|
| 800 |
+
data: {
|
| 801 |
+
labels,
|
| 802 |
+
datasets: [
|
| 803 |
+
{ type:'bar', label:'Daily', data:DAILY.map(x=>x.v),
|
| 804 |
+
backgroundColor:'rgba(34,225,232,0.25)', borderColor:'#22e1e8', borderWidth:1, yAxisID:'y' },
|
| 805 |
+
{ type:'line', label:'Cumulative', data:CUM.map(x=>x.v),
|
| 806 |
+
borderColor:'#7ad7ff', backgroundColor:'rgba(122,215,255,0.12)',
|
| 807 |
+
borderWidth:2, tension:0.25, pointRadius:0, yAxisID:'y1' }
|
| 808 |
+
]
|
| 809 |
+
},
|
| 810 |
+
options:{
|
| 811 |
+
responsive:true, maintainAspectRatio:false,
|
| 812 |
+
interaction:{mode:'index',intersect:false},
|
| 813 |
+
plugins:{legend:{display:true}},
|
| 814 |
+
scales:{
|
| 815 |
+
x:{ grid:{color:'rgba(255,255,255,.05)'}, border:{color:'rgba(255,255,255,.1)'} },
|
| 816 |
+
y:{ position:'left', grid:{color:'rgba(255,255,255,.05)'}, border:{color:'rgba(255,255,255,.1)'},
|
| 817 |
+
ticks:{ callback:(v)=> v.toLocaleString() } },
|
| 818 |
+
y1:{ position:'right', grid:{ drawOnChartArea:false }, border:{color:'rgba(255,255,255,.1)'},
|
| 819 |
+
ticks:{ callback:(v)=> v.toLocaleString() } }
|
| 820 |
+
}
|
| 821 |
+
}
|
| 822 |
+
});
|
| 823 |
+
|
| 824 |
+
// Keep standalone cumulative chart below
|
| 825 |
+
const optsLine={
|
| 826 |
+
responsive:true, maintainAspectRatio:false, animation:false,
|
| 827 |
+
plugins:{legend:{display:false}},
|
| 828 |
+
scales:{
|
| 829 |
+
x:{ grid:{color:'rgba(255,255,255,.05)'}, border:{color:'rgba(255,255,255,.1)'} },
|
| 830 |
+
y:{ grid:{color:'rgba(255,255,255,.05)'}, border:{color:'rgba(255,255,255,.1)'},
|
| 831 |
+
ticks:{ callback:(v)=> v.toLocaleString() } }
|
| 832 |
+
}
|
| 833 |
+
};
|
| 834 |
+
charts.cum=new Chart(document.getElementById('chartCum'),{
|
| 835 |
+
type:'line',
|
| 836 |
+
data:{labels, datasets:[{label:'Cumulative', data:CUM.map(x=>x.v),
|
| 837 |
+
borderColor:'#7ad7ff', backgroundColor:'rgba(122,215,255,0.1)',
|
| 838 |
+
borderWidth:2, tension:0.25, pointRadius:0}]},
|
| 839 |
+
options:optsLine
|
| 840 |
+
});
|
| 841 |
+
|
| 842 |
+
charts.top=new Chart(document.getElementById('chartTop'),{
|
| 843 |
+
type:'bar',
|
| 844 |
+
data:{labels:TOP.map(t=>t[0]), datasets:[{data:TOP.map(t=>t[1]),
|
| 845 |
+
backgroundColor: TOP.map((_,i)=>['#22e1e8','#7ad7ff','#a87fff','#f5a524','#86efac','#f08ac3','#ffa055','#35b5ff','#1dc6c2','#c28bff'][i%10]),
|
| 846 |
+
borderColor:'#111', borderWidth:1}]},
|
| 847 |
+
options:optsLine
|
| 848 |
+
});
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
function pieOptions(){return {
|
| 852 |
+
responsive:true,
|
| 853 |
+
maintainAspectRatio:false,
|
| 854 |
+
plugins:{
|
| 855 |
+
legend:{
|
| 856 |
+
display:true,
|
| 857 |
+
position:'right',
|
| 858 |
+
labels:{
|
| 859 |
+
usePointStyle: true,
|
| 860 |
+
color: '#cfe0ff',
|
| 861 |
+
font: { size: 12 },
|
| 862 |
+
padding: 20
|
| 863 |
+
}
|
| 864 |
+
}
|
| 865 |
+
}
|
| 866 |
+
}}
|
| 867 |
+
function drawMafePie(){
|
| 868 |
+
const ctx=document.getElementById('chartMafe'); if(!ctx) return;
|
| 869 |
+
if (charts.mafe) charts.mafe.destroy();
|
| 870 |
+
if (!FILTERED.length){
|
| 871 |
+
charts.mafe = new Chart(ctx,{
|
| 872 |
+
type:'pie',
|
| 873 |
+
data:{labels:[],datasets:[{data:[]}]},
|
| 874 |
+
options:pieOptions()
|
| 875 |
+
});
|
| 876 |
+
return;
|
| 877 |
+
}
|
| 878 |
+
const totals=new Map();
|
| 879 |
+
FILTERED.forEach(r=>{const k=r.mafe_raw||'—'; totals.set(k,(totals.get(k)||0)+(r.total||0));});
|
| 880 |
+
const labels=Array.from(totals.keys());
|
| 881 |
+
const data=labels.map(k=>totals.get(k));
|
| 882 |
+
|
| 883 |
+
charts.mafe=new Chart(ctx,{
|
| 884 |
+
type:'pie',
|
| 885 |
+
data:{
|
| 886 |
+
labels,
|
| 887 |
+
datasets:[{
|
| 888 |
+
data,
|
| 889 |
+
backgroundColor: chartColors,
|
| 890 |
+
borderColor: '#0b1222',
|
| 891 |
+
borderWidth: 2
|
| 892 |
+
}]
|
| 893 |
+
},
|
| 894 |
+
options:pieOptions()
|
| 895 |
+
});
|
| 896 |
+
}
|
| 897 |
+
function drawWbsPie(){
|
| 898 |
+
const ctx=document.getElementById('chartWbs'); if(!ctx) return;
|
| 899 |
+
if (charts.wbs) charts.wbs.destroy();
|
| 900 |
+
if (!FILTERED.length){
|
| 901 |
+
charts.wbs = new Chart(ctx,{
|
| 902 |
+
type:'pie',
|
| 903 |
+
data:{labels:[],datasets:[{data:[]}]},
|
| 904 |
+
options:pieOptions()
|
| 905 |
+
});
|
| 906 |
+
return;
|
| 907 |
+
}
|
| 908 |
+
const totals=new Map();
|
| 909 |
+
FILTERED.forEach(r=>{const k=r.wbs||'—'; totals.set(k,(totals.get(k)||0)+(r.total||0));});
|
| 910 |
+
const top=Array.from(totals.entries()).sort((a,b)=>b[1]-a[1]).slice(0,10);
|
| 911 |
+
const labels=top.map(x=>x[0]); const data=top.map(x=>x[1]);
|
| 912 |
+
|
| 913 |
+
charts.wbs=new Chart(ctx,{
|
| 914 |
+
type:'pie',
|
| 915 |
+
data:{
|
| 916 |
+
labels,
|
| 917 |
+
datasets:[{
|
| 918 |
+
data,
|
| 919 |
+
backgroundColor: chartColors,
|
| 920 |
+
borderColor: '#0b1222',
|
| 921 |
+
borderWidth: 2
|
| 922 |
+
}]
|
| 923 |
+
},
|
| 924 |
+
options:pieOptions()
|
| 925 |
+
});
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
|
| 929 |
+
function drawModulePie(){
|
| 930 |
+
const ctx=document.getElementById('chartModule'); if(!ctx) return;
|
| 931 |
+
if (charts.module) charts.module.destroy();
|
| 932 |
+
|
| 933 |
+
const tbody = document.querySelector('#moduleTable tbody');
|
| 934 |
+
if (!FILTERED.length){
|
| 935 |
+
charts.module = new Chart(ctx,{ type:'doughnut', data:{labels:[],datasets:[{data:[]}]}, options:pieOptions() });
|
| 936 |
+
if(tbody) tbody.innerHTML = '<tr><td class="p-2 hint" colspan="4">No data</td></tr>';
|
| 937 |
+
return;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
// Build totals per module
|
| 941 |
+
const totals=new Map();
|
| 942 |
+
FILTERED.forEach(r=>{const k=(r.module||'—'); totals.set(k,(totals.get(k)||0)+(r.total||0));});
|
| 943 |
+
// Sort by value desc
|
| 944 |
+
const entries = Array.from(totals.entries()).sort((a,b)=>b[1]-a[1]);
|
| 945 |
+
const labels=entries.map(x=>x[0]);
|
| 946 |
+
const data=entries.map(x=>x[1]);
|
| 947 |
+
|
| 948 |
+
charts.module=new Chart(ctx,{
|
| 949 |
+
type:'doughnut',
|
| 950 |
+
data:{
|
| 951 |
+
labels,
|
| 952 |
+
datasets:[{
|
| 953 |
+
data,
|
| 954 |
+
backgroundColor: chartColors,
|
| 955 |
+
borderColor: '#0b1222',
|
| 956 |
+
borderWidth: 2
|
| 957 |
+
}]
|
| 958 |
+
},
|
| 959 |
+
options:(function(){
|
| 960 |
+
const base=pieOptions();
|
| 961 |
+
base.cutout='58%';
|
| 962 |
+
if(!base.plugins) base.plugins={};
|
| 963 |
+
base.plugins.legend={display:false};
|
| 964 |
+
base.plugins.tooltip={callbacks:{label:(c)=>{
|
| 965 |
+
const v=c.parsed;
|
| 966 |
+
const sum = data.reduce((a,b)=>a+b,0);
|
| 967 |
+
const pct = sum? (v/sum*100).toFixed(1) : '—';
|
| 968 |
+
return `${c.label}: ${fmt(v)} (${pct}%)`;
|
| 969 |
+
}}};
|
| 970 |
+
base.onHover=(e,elements)=>{
|
| 971 |
+
const rows = document.querySelectorAll('#moduleTable tbody tr');
|
| 972 |
+
rows.forEach(r=>r.style.background='');
|
| 973 |
+
if(elements && elements.length){
|
| 974 |
+
const idx = elements[0].index;
|
| 975 |
+
const row = rows[idx];
|
| 976 |
+
if(row){ row.style.background='rgba(255,255,255,0.06)'; }
|
| 977 |
+
}
|
| 978 |
+
};
|
| 979 |
+
return base;
|
| 980 |
+
})()
|
| 981 |
+
});
|
| 982 |
+
|
| 983 |
+
// Render right-side table
|
| 984 |
+
if(tbody){
|
| 985 |
+
const totalSum = data.reduce((a,b)=>a+b,0);
|
| 986 |
+
tbody.innerHTML = labels.map((name, i)=>{
|
| 987 |
+
const val = data[i];
|
| 988 |
+
const pct = totalSum ? (val/totalSum*100) : 0;
|
| 989 |
+
return `
|
| 990 |
+
<tr data-index="${i}" class="hover:bg-[var(--panel)]">
|
| 991 |
+
<td class="p-2">${name||'—'}</td>
|
| 992 |
+
<td class="p-2 text-right mono">${fmt(val)}</td>
|
| 993 |
+
<td class="p-2 text-right mono">${pct.toFixed(1)}%</td>
|
| 994 |
+
<td class="p-2 text-right mono">${(i+1)}</td>
|
| 995 |
+
</tr>
|
| 996 |
+
`;
|
| 997 |
+
}).join('');
|
| 998 |
+
|
| 999 |
+
// Interactivity: hover and click syncing
|
| 1000 |
+
tbody.querySelectorAll('tr').forEach(tr=>{
|
| 1001 |
+
tr.addEventListener('mouseenter', (ev)=>{
|
| 1002 |
+
const idx = +ev.currentTarget.dataset.index;
|
| 1003 |
+
if(Number.isFinite(idx)){
|
| 1004 |
+
charts.module.setActiveElements([{datasetIndex:0, index:idx}]);
|
| 1005 |
+
charts.module.update();
|
| 1006 |
+
}
|
| 1007 |
+
});
|
| 1008 |
+
tr.addEventListener('mouseleave', ()=>{
|
| 1009 |
+
charts.module.setActiveElements([]);
|
| 1010 |
+
charts.module.update();
|
| 1011 |
+
});
|
| 1012 |
+
tr.addEventListener('click', (ev)=>{
|
| 1013 |
+
const idx = +ev.currentTarget.dataset.index;
|
| 1014 |
+
const ds = charts.module.data.datasets[0];
|
| 1015 |
+
if(ds._isoIndex === idx){
|
| 1016 |
+
delete ds._isoIndex;
|
| 1017 |
+
ds.backgroundColor = labels.map((_,i)=> chartColors[i%chartColors.length]);
|
| 1018 |
+
}else{
|
| 1019 |
+
ds._isoIndex = idx;
|
| 1020 |
+
ds.backgroundColor = labels.map((_,i)=> i===idx ? chartColors[i%chartColors.length] : 'rgba(255,255,255,0.15)');
|
| 1021 |
+
}
|
| 1022 |
+
charts.module.update();
|
| 1023 |
+
});
|
| 1024 |
+
});
|
| 1025 |
+
|
| 1026 |
+
// Toggle buttons for $ vs %
|
| 1027 |
+
const btn$ = document.getElementById('moduleShowDollar');
|
| 1028 |
+
const btnP = document.getElementById('moduleShowPercent');
|
| 1029 |
+
if(btn$ && btnP){
|
| 1030 |
+
btn$.onclick = ()=>{
|
| 1031 |
+
tbody.querySelectorAll('tr').forEach((tr,i)=>{
|
| 1032 |
+
const val = data[i];
|
| 1033 |
+
const pct = totalSum ? (val/totalSum*100) : 0;
|
| 1034 |
+
tr.children[1].textContent = fmt(val);
|
| 1035 |
+
tr.children[2].textContent = pct.toFixed(1) + '%';
|
| 1036 |
+
});
|
| 1037 |
+
};
|
| 1038 |
+
btnP.onclick = ()=>{
|
| 1039 |
+
tbody.querySelectorAll('tr').forEach((tr,i)=>{
|
| 1040 |
+
const val = data[i];
|
| 1041 |
+
const pct = totalSum ? (val/totalSum*100) : 0;
|
| 1042 |
+
tr.children[1].textContent = pct.toFixed(1) + '%';
|
| 1043 |
+
tr.children[2].textContent = fmt(val);
|
| 1044 |
+
});
|
| 1045 |
+
};
|
| 1046 |
+
}
|
| 1047 |
+
}
|
| 1048 |
+
}
|
| 1049 |
+
function
|
| 1050 |
+
calcForecast(){
|
| 1051 |
+
const t=$('targetDate').value?new Date($('targetDate').value):null;
|
| 1052 |
+
if(!t||!DAILY.length){$('k_forecast').textContent='—';$('k_forecast_formula').textContent='—';return;}
|
| 1053 |
+
const end=new Date(DAILY[DAILY.length-1].d);
|
| 1054 |
+
const rem=Math.max(Math.ceil((t-end)/86400000),0);
|
| 1055 |
+
const ac=CUM[CUM.length-1].v;
|
| 1056 |
+
const burn = DAILY.length ? ac/DAILY.length : 0; // average over selected window
|
| 1057 |
+
const eac=ac + rem*burn;
|
| 1058 |
+
$('k_forecast').textContent = fmt(eac);
|
| 1059 |
+
$('k_forecast_formula').textContent = `(${rem} days × ${fmt(burn)})`;
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
// === Loader control ===
|
| 1063 |
+
(function(){
|
| 1064 |
+
const $ = (id)=>document.getElementById(id);
|
| 1065 |
+
const loader = $('cmLoader'), bar = $('cmBar'), pct = $('cmPct'), step = $('cmStep');
|
| 1066 |
+
// path animations setup
|
| 1067 |
+
const curve = document.getElementById('cmCurve');
|
| 1068 |
+
const dot = document.getElementById('cmDot');
|
| 1069 |
+
function animateCurve(){
|
| 1070 |
+
try{
|
| 1071 |
+
const len = curve.getTotalLength();
|
| 1072 |
+
curve.style.strokeDasharray = len + ' ' + len;
|
| 1073 |
+
curve.style.strokeDashoffset = len;
|
| 1074 |
+
curve.style.animation = 'cmDraw 1.6s ease forwards, cmDash 1.2s ease-in-out infinite alternate';
|
| 1075 |
+
// dot motion along path
|
| 1076 |
+
let t=0; const dur=1800;
|
| 1077 |
+
function tick(ts0){
|
| 1078 |
+
const ts = performance.now();
|
| 1079 |
+
const p = (ts % dur) / dur;
|
| 1080 |
+
const pt = curve.getPointAtLength(p*len);
|
| 1081 |
+
dot.setAttribute('cx', pt.x); dot.setAttribute('cy', pt.y);
|
| 1082 |
+
dot.style.animation = 'cmGlow 1.6s ease-in-out infinite';
|
| 1083 |
+
requestAnimationFrame(tick);
|
| 1084 |
+
} requestAnimationFrame(tick);
|
| 1085 |
+
}catch(_){}
|
| 1086 |
+
}
|
| 1087 |
+
window.CM_LOADER = {
|
| 1088 |
+
show(msg='Loading…', v=0){
|
| 1089 |
+
if(loader.style.display!=='flex'){
|
| 1090 |
+
loader.style.display='flex';
|
| 1091 |
+
animateCurve();
|
| 1092 |
+
}
|
| 1093 |
+
step.textContent=msg;
|
| 1094 |
+
bar.style.width = Math.max(0,Math.min(100,v)) + '%';
|
| 1095 |
+
pct.textContent = Math.round(Math.max(v,0)) + '%';
|
| 1096 |
+
},
|
| 1097 |
+
step(msg, v){
|
| 1098 |
+
step.textContent=msg;
|
| 1099 |
+
if(typeof v==='number'){
|
| 1100 |
+
bar.style.width = Math.max(0,Math.min(100,v)) + '%';
|
| 1101 |
+
pct.textContent = Math.round(Math.max(v,0)) + '%';
|
| 1102 |
+
}
|
| 1103 |
+
},
|
| 1104 |
+
hide(){
|
| 1105 |
+
loader.style.display='none';
|
| 1106 |
+
bar.style.width='0%';
|
| 1107 |
+
pct.textContent='0%';
|
| 1108 |
+
step.textContent='';
|
| 1109 |
+
}
|
| 1110 |
+
};
|
| 1111 |
+
})();
|
| 1112 |
+
|
| 1113 |
+
// --- toast control ---
|
| 1114 |
+
(function(){
|
| 1115 |
+
const t = document.getElementById('cmToast');
|
| 1116 |
+
const msg = document.getElementById('cmToastMsg');
|
| 1117 |
+
window.CM_TOAST = {
|
| 1118 |
+
show(m='Loading file… please wait'){
|
| 1119 |
+
if(t){
|
| 1120 |
+
msg.textContent=m;
|
| 1121 |
+
t.style.display='block';
|
| 1122 |
+
}
|
| 1123 |
+
},
|
| 1124 |
+
hide(){
|
| 1125 |
+
if(t){
|
| 1126 |
+
t.style.display='none';
|
| 1127 |
+
}
|
| 1128 |
+
}
|
| 1129 |
+
};
|
| 1130 |
+
})();
|
| 1131 |
+
|
| 1132 |
+
// Upload & controls
|
| 1133 |
+
document.getElementById('fileLabel').addEventListener('keydown',(e)=>{
|
| 1134 |
+
if(e.key==='Enter' || e.key===' '){
|
| 1135 |
+
e.preventDefault();
|
| 1136 |
+
const inp=$('file');
|
| 1137 |
+
if(inp){
|
| 1138 |
+
try{
|
| 1139 |
+
inp.click();
|
| 1140 |
+
}catch(_){}
|
| 1141 |
+
}
|
| 1142 |
+
}
|
| 1143 |
+
});
|
| 1144 |
+
document.getElementById('fileLabel').addEventListener('pointerdown',()=>{ $('file').value=''; });
|
| 1145 |
+
// Extra safety: explicit click to open the chooser across browsers
|
| 1146 |
+
document.getElementById('fileLabel').addEventListener('click',(e)=>{
|
| 1147 |
+
e.preventDefault();
|
| 1148 |
+
const inp=$('file');
|
| 1149 |
+
if(inp){
|
| 1150 |
+
try{
|
| 1151 |
+
inp.click();
|
| 1152 |
+
}catch(_){}
|
| 1153 |
+
}
|
| 1154 |
+
});
|
| 1155 |
+
$('file').addEventListener('change',(e)=>{
|
| 1156 |
+
const f=e.target.files && e.target.files[0];
|
| 1157 |
+
if(!f) return;
|
| 1158 |
+
const spinner = document.getElementById('uploadSpinner');
|
| 1159 |
+
if(spinner) {
|
| 1160 |
+
spinner.style.display = 'block';
|
| 1161 |
+
document.querySelector('.cm-dot').style.display = 'none';
|
| 1162 |
+
}
|
| 1163 |
+
CM_TOAST.show('Uploading file...');
|
| 1164 |
+
$('filename').textContent=f.name;
|
| 1165 |
+
const lower=f.name.toLowerCase();
|
| 1166 |
+
const afterLoad=()=>{
|
| 1167 |
+
FILTERED=RAW.slice();
|
| 1168 |
+
seedRange();
|
| 1169 |
+
buildPicker();
|
| 1170 |
+
buildMafeDropdown();
|
| 1171 |
+
buildModuleDropdown();
|
| 1172 |
+
applyFilters();
|
| 1173 |
+
};
|
| 1174 |
+
if(lower.endsWith('.csv')){
|
| 1175 |
+
try{ CM_LOADER.step('Parsing CSV…', 18); }catch(_){}
|
| 1176 |
+
Papa.parse(f,{header:true,skipEmptyLines:true,dynamicTyping:true,complete:r=>{
|
| 1177 |
+
try{ CM_LOADER.step('Mapping rows…', 55); }catch(_){}
|
| 1178 |
+
RAW=r.data.map(mapRow).filter(x=>x.ts&&!isNaN(x.total));
|
| 1179 |
+
try{ CM_LOADER.step('Aggregating…', 72); }catch(_){}
|
| 1180 |
+
if(!RAW.length){alert('No valid rows'); CM_TOAST.hide(); return;}
|
| 1181 |
+
afterLoad();
|
| 1182 |
+
try{ CM_LOADER.step('Rendering…', 90); }catch(_){}
|
| 1183 |
+
CM_TOAST.hide();
|
| 1184 |
+
const spinner = document.getElementById('uploadSpinner');
|
| 1185 |
+
if(spinner) {
|
| 1186 |
+
spinner.style.display = 'none';
|
| 1187 |
+
document.querySelector('.cm-dot').style.display = 'block';
|
| 1188 |
+
}
|
| 1189 |
+
}});
|
| 1190 |
+
}else{
|
| 1191 |
+
const fr=new FileReader();
|
| 1192 |
+
fr.onload=ev=>{
|
| 1193 |
+
try{ CM_LOADER.step('Reading workbook…', 28); }catch(_){}
|
| 1194 |
+
const wb=XLSX.read(new Uint8Array(ev.target.result),{type:'array'});
|
| 1195 |
+
const sh=wb.SheetNames[0];
|
| 1196 |
+
const js=XLSX.utils.sheet_to_json(wb.Sheets[sh],{defval:'',raw:true});
|
| 1197 |
+
try{ CM_LOADER.step('Mapping rows…', 56); }catch(_){}
|
| 1198 |
+
RAW=js.map(mapRow).filter(x=>x.ts&&!isNaN(x.total));
|
| 1199 |
+
if(!RAW.length){alert('No valid rows');return;}
|
| 1200 |
+
afterLoad();
|
| 1201 |
+
try{ CM_LOADER.step('Rendering…', 90); }catch(_){}
|
| 1202 |
+
};
|
| 1203 |
+
fr.readAsArrayBuffer(f);
|
| 1204 |
+
}
|
| 1205 |
+
});
|
| 1206 |
+
document.addEventListener('click',e=>{
|
| 1207 |
+
if(e.target.matches('.quick[data-last]')){ e.preventDefault(); setLast(+e.target.dataset.last); }
|
| 1208 |
+
if(e.target.id==='allData'){ e.preventDefault(); setAll(); }
|
| 1209 |
+
});
|
| 1210 |
+
$('rangeMin').addEventListener('input',updateRangeUI);
|
| 1211 |
+
$('rangeMax').addEventListener('input',updateRangeUI);
|
| 1212 |
+
$('targetDate').addEventListener('change', calcForecast);
|
| 1213 |
+
$('exportCsv').onclick=()=>{
|
| 1214 |
+
if(!DAILY.length) return;
|
| 1215 |
+
const csv="Date,Actual,Cumulative\n"+DAILY.map((d,i)=>`${d.d},${d.v},${CUM[i].v}`).join('\n');
|
| 1216 |
+
saveAs(new Blob([csv],{type:'text/csv'}),"CostMaster_Filtered.csv");
|
| 1217 |
+
};
|
| 1218 |
+
$('resetAll').onclick=()=>{
|
| 1219 |
+
RAW=[];FILTERED=[];DAILY=[];CUM=[];TOP=[];ALL_DATES=[];SELECTED.clear(); MAFE='ALL'; MODULE='ALL';
|
| 1220 |
+
Object.values(charts).forEach(c=>c?.destroy()); charts={};
|
| 1221 |
+
['k_total','k_window','k_avg','k_forecast','k_forecast_formula','h_st','h_ot','h_dt','c_st','c_ot','c_dt','c_tot'].forEach(id=>$(id).textContent='—');
|
| 1222 |
+
$('list').innerHTML=''; $('chips').innerHTML=''; $('filename').textContent='No file selected';
|
| 1223 |
+
$('topList').innerHTML=''; $('rangeMinBadge').textContent='—'; $('rangeMaxBadge').textContent='—';
|
| 1224 |
+
$('file').value=''; $('mafeFilter').value='ALL'; MAFE_EXACT='ALL'; $('moduleFilter').value='ALL';
|
| 1225 |
+
$('search').value=''; $('targetDate').value='';
|
| 1226 |
+
$('rangeMin').value=0; $('rangeMax').value=0;
|
| 1227 |
+
};
|
| 1228 |
+
$('mafeFilter').addEventListener('change', (e)=>{ const v=e.target.value; MAFE_EXACT=v; MAFE='ALL'; applyFilters(); });
|
| 1229 |
+
|
| 1230 |
+
function buildMafeDropdown(){
|
| 1231 |
+
const sel = document.getElementById('mafeFilter');
|
| 1232 |
+
if(!sel) return;
|
| 1233 |
+
const uniques = Array.from(new Set(RAW.map(r => (r.mafe_raw||'').trim()).filter(Boolean))).sort((a,b)=>a.localeCompare(b));
|
| 1234 |
+
const cur = sel.value;
|
| 1235 |
+
sel.innerHTML = '';
|
| 1236 |
+
const optAll = document.createElement('option'); optAll.value='ALL'; optAll.textContent='All'; sel.appendChild(optAll);
|
| 1237 |
+
uniques.forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; sel.appendChild(o); });
|
| 1238 |
+
sel.value = uniques.includes(cur) ? cur : 'ALL';
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
function buildModuleDropdown(){
|
| 1242 |
+
const sel = document.getElementById('moduleFilter');
|
| 1243 |
+
if(!sel) return;
|
| 1244 |
+
const uniques = Array.from(new Set(RAW.map(r => (r.module||'').trim()).filter(Boolean))).sort((a,b)=>a.localeCompare(b));
|
| 1245 |
+
const cur = sel.value;
|
| 1246 |
+
sel.innerHTML = '';
|
| 1247 |
+
const optAll = document.createElement('option'); optAll.value='ALL'; optAll.textContent='All'; sel.appendChild(optAll);
|
| 1248 |
+
uniques.forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; sel.appendChild(o); });
|
| 1249 |
+
sel.value = uniques.includes(cur) ? cur : 'ALL';
|
| 1250 |
+
sel.onchange = (e)=>{ MODULE = e.target.value || 'ALL'; applyFilters(); };
|
| 1251 |
+
}
|
| 1252 |
+
|
| 1253 |
+
// Set up interactive features
|
| 1254 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 1255 |
+
// Initialize any elements that need setup after the DOM is loaded
|
| 1256 |
+
// The file input handling is already set up in the script above
|
| 1257 |
+
});
|
| 1258 |
+
</script>
|
| 1259 |
+
|
| 1260 |
+
<script>
|
| 1261 |
+
// ===== Safe-Mode PDF Export (no globals, white pages, aspect-fit) =====
|
| 1262 |
+
(function(){
|
| 1263 |
+
function addImageFit(pdf, dataURL, x, y, maxW, maxH){
|
| 1264 |
+
const img = new Image();
|
| 1265 |
+
return new Promise(res=>{
|
| 1266 |
+
img.onload = () => {
|
| 1267 |
+
const iw = img.width, ih = img.height;
|
| 1268 |
+
const r = Math.min(maxW/iw, maxH/ih);
|
| 1269 |
+
const w = Math.max(10, Math.floor(iw*r));
|
| 1270 |
+
const h = Math.max(10, Math.floor(ih*r));
|
| 1271 |
+
pdf.addImage(dataURL, 'PNG', x, y, w, h, undefined, 'FAST');
|
| 1272 |
+
res();
|
| 1273 |
+
};
|
| 1274 |
+
img.src = dataURL;
|
| 1275 |
+
});
|
| 1276 |
+
}
|
| 1277 |
+
async function snap(selector, scale=2){
|
| 1278 |
+
const el = document.querySelector(selector);
|
| 1279 |
+
if(!el) return null;
|
| 1280 |
+
const canvas = await html2canvas(el, {backgroundColor:'#ffffff', scale});
|
| 1281 |
+
return canvas.toDataURL('image/png', 0.98);
|
| 1282 |
+
}
|
| 1283 |
+
async function exportReportPDF(){
|
| 1284 |
+
try{
|
| 1285 |
+
if(!(window.jspdf && window.jspdf.jsPDF) || !window.html2canvas){
|
| 1286 |
+
alert('PDF libraries not loaded. Check the two <script> tags in <head>.');
|
| 1287 |
+
return;
|
| 1288 |
+
}
|
| 1289 |
+
const { jsPDF } = window.jspdf;
|
| 1290 |
+
const pdf = new jsPDF({orientation:'landscape', unit:'pt', format:'a4'});
|
| 1291 |
+
const M = 36, W = pdf.internal.pageSize.getWidth(), H = pdf.internal.pageSize.getHeight();
|
| 1292 |
+
const BOXW = W - 2*M, BOXH = H - 2*M;
|
| 1293 |
+
|
| 1294 |
+
// Title Page
|
| 1295 |
+
pdf.setFillColor(255,255,255); pdf.rect(0,0,W,H,'F'); pdf.setTextColor(0,0,0);
|
| 1296 |
+
pdf.setFont('helvetica','bold'); pdf.setFontSize(20); pdf.text('Cost Master — Project Report', M, 46);
|
| 1297 |
+
pdf.setFont('helvetica','normal'); pdf.setFontSize(12);
|
| 1298 |
+
const now = new Date();
|
| 1299 |
+
const dr = (document.getElementById('dateRange')?.textContent || document.getElementById('k_window')?.textContent || '').trim();
|
| 1300 |
+
const mafeRows = FILTERED.filter(r => (r.mafe||'').toUpperCase().includes('MAFE')).length;
|
| 1301 |
+
const afeRows = FILTERED.filter(r => (r.mafe||'').toUpperCase().includes('AFE')).length;
|
| 1302 |
+
const tot = (document.querySelector('#ov_cost b')?.textContent || document.getElementById('k_total')?.textContent || '').trim();
|
| 1303 |
+
const burn= (document.querySelector('#ov_burn b')?.textContent || document.getElementById('k_avg')?.textContent || '').trim();
|
| 1304 |
+
const hST = (document.getElementById('h_st')?.textContent || '').trim();
|
| 1305 |
+
const hOT = (document.getElementById('h_ot')?.textContent || '').trim();
|
| 1306 |
+
const hDT = (document.getElementById('h_dt')?.textContent || '').trim();
|
| 1307 |
+
const hTOT= (document.getElementById('h_tot')?.textContent || document.getElementById('h_total')?.textContent || '').trim();
|
| 1308 |
+
let y = 76;
|
| 1309 |
+
pdf.text(`Generated: ${now.toLocaleString()}`, M, y); y+=20;
|
| 1310 |
+
if(dr){ pdf.text(`Date Range: ${dr}`, M, y); y+=26; }
|
| 1311 |
+
if(tot){ pdf.text(`Total Cost: ${tot}`, M, y); y+=18; }
|
| 1312 |
+
if(burn){ pdf.text(`Avg Daily Burn: ${burn}`, M, y); y+=18; }
|
| 1313 |
+
pdf.text(`Hours (DFL): ST ${hST} | OT ${hOT} | DT ${hDT} | Total ${hTOT}`, M, y);
|
| 1314 |
+
|
| 1315 |
+
// Helper to add chart by selector
|
| 1316 |
+
async function addChart(title, selector){
|
| 1317 |
+
const dataURL = await snap(selector);
|
| 1318 |
+
if(!dataURL) return;
|
| 1319 |
+
pdf.addPage('a4','landscape');
|
| 1320 |
+
pdf.setFillColor(255,255,255); pdf.rect(0,0,W,H,'F'); pdf.setTextColor(0,0,0);
|
| 1321 |
+
pdf.setFont('helvetica','bold'); pdf.setFontSize(16); pdf.text(title, M, 40);
|
| 1322 |
+
await addImageFit(pdf, dataURL, M, 56, BOXW, BOXH-40);
|
| 1323 |
+
}
|
| 1324 |
+
await addChart('Daily Cost (bars) + Cumulative (line)', '#chartDaily');
|
| 1325 |
+
await addChart('Cumulative Cost', '#chartCum');
|
| 1326 |
+
await addChart('Cost by Module', '#chartModule');
|
| 1327 |
+
await addChart('Top Categories', '#chartTop');
|
| 1328 |
+
await addChart('Contractor Compare', '#chartCompare');
|
| 1329 |
+
await addChart('MAFE vs AFE - Cost Comparison', '#afeMafeCost');
|
| 1330 |
+
await addChart('MAFE vs AFE - Hours Comparison', '#afeMafeHours');
|
| 1331 |
+
// Page numbers
|
| 1332 |
+
const pc = pdf.getNumberOfPages();
|
| 1333 |
+
pdf.setFont('helvetica','normal'); pdf.setFontSize(9);
|
| 1334 |
+
for(let i=1;i<=pc;i++){ pdf.setPage(i); pdf.text(String(i)+' / '+String(pc), W - M, H - 10, {align:'right'}); }
|
| 1335 |
+
|
| 1336 |
+
pdf.save('CostMaster_Report.pdf');
|
| 1337 |
+
}catch(err){ console.error(err); alert('PDF export failed: '+(err?.message||err)); }
|
| 1338 |
+
}
|
| 1339 |
+
document.addEventListener('DOMContentLoaded', ()=>{
|
| 1340 |
+
const btn = document.getElementById('exportPdf');
|
| 1341 |
+
if(btn){ btn.onclick = exportReportPDF; }
|
| 1342 |
+
});
|
| 1343 |
+
})();
|
| 1344 |
+
</script>
|
| 1345 |
+
|
| 1346 |
+
|
| 1347 |
+
<script>
|
| 1348 |
+
(function(){
|
| 1349 |
+
const $ = id => document.getElementById(id);
|
| 1350 |
+
const pick = (...sels)=>sels.map(s=>document.querySelector(s)).find(Boolean);
|
| 1351 |
+
const toNum = v => Number(String(v||'').replace(/[^\d.-]/g,'')) || 0;
|
| 1352 |
+
const usd = n => n.toLocaleString(undefined,{style:'currency',currency:'USD',maximumFractionDigits:0});
|
| 1353 |
+
const parseNum = v => { if(v==null) return 0; const s=(''+v).replace(/[^\d.\-]/g,''); const n=Number(s); return Number.isFinite(n)?n:0; };
|
| 1354 |
+
|
| 1355 |
+
const manualRateEl = $('mc_manualRate');
|
| 1356 |
+
const uncEl = $('mc_uncertainty'), uncLbl=$('mc_uncertaintyLabel');
|
| 1357 |
+
const HEl = $('mc_horizon'), NEl = $('mc_iters');
|
| 1358 |
+
const runBtn = $('mc_runBtn'), exportBtn = $('mc_exportBtn');
|
| 1359 |
+
const kCur=$('mc_kpiCurrent'), k10=$('mc_kpiP10'), k50=$('mc_kpiP50'), k90=$('mc_kpiP90');
|
| 1360 |
+
const winBtns = ['mc_win_30','mc_win_45','mc_win_60'].map(id=>$(id));
|
| 1361 |
+
let activeWindow = 30;
|
| 1362 |
+
winBtns.forEach(b=>b&&b.addEventListener('click',()=>{
|
| 1363 |
+
activeWindow = +b.dataset.window;
|
| 1364 |
+
winBtns.forEach(x=>x.style.opacity = (x===b)? '1' : '.7');
|
| 1365 |
+
}));
|
| 1366 |
+
if(winBtns[0]) winBtns[0].style.opacity='1';
|
| 1367 |
+
|
| 1368 |
+
uncEl.addEventListener('input', ()=> uncLbl.textContent = (uncEl.value|0)+'%');
|
| 1369 |
+
|
| 1370 |
+
function boxMuller(){
|
| 1371 |
+
let u=0,v=0; while(u===0) u=Math.random(); while(v===0) v=Math.random();
|
| 1372 |
+
return Math.sqrt(-2.0*Math.log(u))*Math.cos(2.0*Math.PI*v);
|
| 1373 |
+
}
|
| 1374 |
+
function buildDateLabels(H){
|
| 1375 |
+
const out=[]; const base=new Date();
|
| 1376 |
+
for(let i=1;i<=H;i++){ const d=new Date(base); d.setDate(d.getDate()+i); out.push(d.toLocaleDateString(undefined,{month:'short',day:'2-digit'})); }
|
| 1377 |
+
return out;
|
| 1378 |
+
}
|
| 1379 |
+
function getCurrentTotal(){ return toNum(pick('#k_total','#total-cost')?.textContent); }
|
| 1380 |
+
function getAvgBurn(){ return toNum(pick('#k_avg','#avg-daily-burn')?.textContent); }
|
| 1381 |
+
|
| 1382 |
+
const ctx = document.getElementById('mc_fanChart').getContext('2d');
|
| 1383 |
+
let chart = new Chart(ctx, {
|
| 1384 |
+
type:'line',
|
| 1385 |
+
data:{datasets:[]},
|
| 1386 |
+
options:{
|
| 1387 |
+
responsive:true, maintainAspectRatio:false, animation:false,
|
| 1388 |
+
interaction:{mode:'index', intersect:false},
|
| 1389 |
+
plugins:{legend:{display:true, labels:{color:'#cfe0ff'}}},
|
| 1390 |
+
scales:{
|
| 1391 |
+
x:{ticks:{color:'#cfe0ff'}, grid:{color:'rgba(255,255,255,.06)'}},
|
| 1392 |
+
y:{ticks:{color:'#cfe0ff'}, grid:{color:'rgba(255,255,255,.06)'}}
|
| 1393 |
+
}
|
| 1394 |
+
}
|
| 1395 |
+
});
|
| 1396 |
+
|
| 1397 |
+
function exportCSV(labels, base, p10, p50, p90){
|
| 1398 |
+
const header='Date,Baseline,P10,P50,P90\n';
|
| 1399 |
+
const rows=labels.map((lab,i)=>`${lab},${Math.round(base)},${Math.round(p10[i]||0)},${Math.round(p50[i]||0)},${Math.round(p90[i]||0)}`).join('\n');
|
| 1400 |
+
const blob=new Blob([header+rows],{type:'text/csv'}); const url=URL.createObjectURL(blob);
|
| 1401 |
+
const a=document.createElement('a'); a.href=url; a.download='mc_cost_projection.csv'; a.click(); URL.revokeObjectURL(url);
|
| 1402 |
+
}
|
| 1403 |
+
|
| 1404 |
+
function runMC(){
|
| 1405 |
+
const baseRate = parseNum(manualRateEl.value) || getAvgBurn();
|
| 1406 |
+
const U = (+uncEl.value||0)/100;
|
| 1407 |
+
const H = Math.max(1, +HEl.value||60);
|
| 1408 |
+
const N = Math.max(100, +NEl.value||5000);
|
| 1409 |
+
const currentTotal = getCurrentTotal();
|
| 1410 |
+
|
| 1411 |
+
if(kCur) kCur.textContent = usd(currentTotal||0);
|
| 1412 |
+
|
| 1413 |
+
if(!(baseRate>0)){
|
| 1414 |
+
chart.data.datasets=[]; chart.update();
|
| 1415 |
+
[k10,k50,k90].forEach(k=>k.textContent='$—');
|
| 1416 |
+
return;
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
const series=[];
|
| 1420 |
+
for(let i=0;i<N;i++){
|
| 1421 |
+
let cum=currentTotal; const path=new Array(H);
|
| 1422 |
+
for(let d=0;d<H;d++){
|
| 1423 |
+
const z=boxMuller();
|
| 1424 |
+
const today=Math.max(0, baseRate + z*(U*baseRate));
|
| 1425 |
+
cum+=today; path[d]=cum;
|
| 1426 |
+
}
|
| 1427 |
+
series.push(path);
|
| 1428 |
+
}
|
| 1429 |
+
|
| 1430 |
+
const p10=[],p50=[],p90=[];
|
| 1431 |
+
for(let d=0;d<H;d++){
|
| 1432 |
+
const col=series.map(r=>r[d]).sort((a,b)=>a-b);
|
| 1433 |
+
p10.push(col[Math.floor(0.10*(N-1))]);
|
| 1434 |
+
p50.push(col[Math.floor(0.50*(N-1))]);
|
| 1435 |
+
p90.push(col[Math.floor(0.90*(N-1))]);
|
| 1436 |
+
}
|
| 1437 |
+
|
| 1438 |
+
if(k10) k10.textContent=usd(p10[H-1]);
|
| 1439 |
+
if(k50) k50.textContent=usd(p50[H-1]);
|
| 1440 |
+
if(k90) k90.textContent=usd(p90[H-1]);
|
| 1441 |
+
|
| 1442 |
+
const labels=buildDateLabels(H);
|
| 1443 |
+
chart.data.datasets=[
|
| 1444 |
+
{label:'P90',data:p90,borderColor:'#f08ac3',backgroundColor:'rgba(240,138,195,.15)',fill:1,tension:.25},
|
| 1445 |
+
{label:'P50',data:p50,borderColor:'#ffc455',backgroundColor:'rgba(255,196,85,.15)',fill:false,tension:.25},
|
| 1446 |
+
{label:'P10',data:p10,borderColor:'#22e1e8',backgroundColor:'rgba(34,225,232,.15)',fill:-1,tension:.25}
|
| 1447 |
+
];
|
| 1448 |
+
chart.data.labels=labels; chart.update();
|
| 1449 |
+
|
| 1450 |
+
exportBtn.onclick=()=>exportCSV(labels,currentTotal,p10,p50,p90);
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
// initial paint
|
| 1454 |
+
const ct0 = getCurrentTotal();
|
| 1455 |
+
if (kCur && ct0>0) kCur.textContent = usd(ct0);
|
| 1456 |
+
if (!manualRateEl.value) {
|
| 1457 |
+
const b0 = getAvgBurn(); if (b0>0) manualRateEl.value = Math.round(b0);
|
| 1458 |
+
}
|
| 1459 |
+
runBtn.addEventListener('click', runMC);
|
| 1460 |
+
|
| 1461 |
+
// keep synced if overview changes
|
| 1462 |
+
let raf=0; const sync=()=>{ if(raf) cancelAnimationFrame(raf); raf=requestAnimationFrame(()=>{
|
| 1463 |
+
const ct=getCurrentTotal(); if(kCur && ct>0) kCur.textContent=usd(ct);
|
| 1464 |
+
if(!manualRateEl.value){ const b=getAvgBurn(); if(b>0) manualRateEl.value=Math.round(b); }
|
| 1465 |
+
});};
|
| 1466 |
+
['#k_total','#total-cost','#k_avg','#avg-daily-burn'].forEach(sel=>{
|
| 1467 |
+
const n=document.querySelector(sel); if(n) new MutationObserver(sync).observe(n,{subtree:true,childList:true,characterData:true});
|
| 1468 |
+
});
|
| 1469 |
+
})();
|
| 1470 |
+
</script>
|
| 1471 |
+
</body>
|
| 1472 |
+
</html>
|