añadido el tema para el indice de control

This commit is contained in:
2025-11-10 21:33:33 -03:00
parent 2d60fcc9cd
commit f0b7b349e7
9 changed files with 371 additions and 0 deletions

View File

@@ -5,6 +5,7 @@
"name": "redmine-api-administracion",
"dependencies": {
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"html2canvas": "^1.4.1",
"marked": "^16.4.1",
},
@@ -82,6 +83,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="],
@@ -150,6 +153,8 @@
"bootstrap": ["bootstrap@5.3.8", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],

View File

@@ -20,6 +20,7 @@
},
"dependencies": {
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"html2canvas": "^1.4.1",
"marked": "^16.4.1"
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import Header from "./componentes/header.svelte";
import CalcularDias from "./componentes/paginas/CalcularDias.svelte";
import IndiceDeControl from "./componentes/paginas/IndiceDeControl.svelte";
import Tarjeta from "./componentes/tarjeta.svelte";
import html2canvas from "html2canvas";
@@ -78,5 +79,7 @@
{/if}
{:else if pagina === 1}
<CalcularDias {issues} />
{:else if pagina === 2}
<IndiceDeControl {issues} />
{/if}
</main>

76
src/assets/fpinga.py Normal file
View File

@@ -0,0 +1,76 @@
import json
from datetime import datetime
import pandas as pd
import matplotlib.pyplot as plt
# === CONFIGURACIÓN ===
archivo_issues = "issues.json" # tu archivo exportado
presupuesto_total = 41980.0 # presupuesto del proyecto
# === FUNCIONES AUXILIARES ===
def parse_date(valor):
if not valor:
return None
try:
return datetime.fromisoformat(valor.replace("Z", "+00:00")).date()
except:
try:
return datetime.strptime(valor.split("T")[0], "%Y-%m-%d").date()
except:
return None
# === CARGA DE DATOS ===
with open(archivo_issues, "r", encoding="utf-8") as f:
data = json.load(f)
issues = data.get("issues", [])
for it in issues:
it["_start"] = parse_date(it.get("start_date"))
it["_due"] = parse_date(it.get("due_date"))
it["_closed"] = parse_date(it.get("closed_on"))
it["_done"] = float(it.get("done_ratio", 0)) / 100.0
# === GENERAR TIMELINE ===
fechas = []
for it in issues:
for f in [it["_start"], it["_due"], it["_closed"]]:
if f:
fechas.append(f)
min_fecha, max_fecha = min(fechas), max(fechas)
timeline = pd.date_range(start=min_fecha, end=max_fecha, freq="D")
# === CALCULAR PV y EV ===
total = len(issues)
pv_vals, ev_vals, fechas_plot = [], [], []
for d in timeline:
fecha = d.date()
pv = sum(1 for it in issues if it["_due"] and it["_due"] <= fecha) / total
ev = sum(it["_done"] for it in issues if it["_closed"] and it["_closed"] <= fecha)
ev += sum(it["_done"] for it in issues if (not it["_closed"]) and it["_due"] and it["_due"] <= fecha)
ev = min(ev, total)
pv_vals.append(pv * presupuesto_total)
ev_vals.append(ev / total * presupuesto_total)
fechas_plot.append(fecha)
# === GRÁFICO CURVA S ===
plt.figure(figsize=(10, 5))
plt.plot(fechas_plot, pv_vals, label="PV (Valor planificado)", color="blue")
plt.plot(fechas_plot, ev_vals, label="EV (Valor ganado)", color="green")
plt.title("Curva S — Proyecto AlquilaFacil")
plt.xlabel("Fecha")
plt.ylabel("Valor acumulado (USD)")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
# === RESUMEN FINAL ===
print("----- RESUMEN CURVA S -----")
print(f"Fecha inicio: {min_fecha}")
print(f"Fecha fin: {max_fecha}")
print(f"Presupuesto total: {presupuesto_total}")
print(f"PV final: {pv_vals[-1]:.2f}")
print(f"EV final: {ev_vals[-1]:.2f}")
print(f"Issues totales: {total}")

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from "svelte";
import { Chart, type ChartConfiguration } from "chart.js/auto";
import html2canvas from "html2canvas";
import type { Issue } from "../types";
let { issues, cpi = 0.96 }: { issues: Issue[]; cpi: number } = $props();
const presupuesto = 41980;
function parseDate(s: string | null | undefined): Date | null {
if (!s) return null;
return new Date(s.split("T")[0] + "T00:00:00");
}
const dates: string[] = [];
const pvValues: number[] = [];
const evValues: number[] = [];
if (issues && issues.length) {
// 1⃣ Determinar rango de fechas (inicio y fin global)
let minDate: Date | null = null;
let maxDate: Date | null = null;
for (const i of issues) {
const s = parseDate(i.start_date);
const d = parseDate(i.due_date);
const c = parseDate(i.closed_on);
for (const dt of [s, d, c]) {
if (!dt) continue;
if (!minDate || dt < minDate) minDate = dt;
if (!maxDate || dt > maxDate) maxDate = dt;
}
}
// fallback a hoy si faltan fechas
minDate ??= new Date();
maxDate ??= new Date();
// 2⃣ Generar timeline diaria
for (
let d = new Date(minDate);
d <= maxDate;
d.setDate(d.getDate() + 1)
) {
dates.push(d.toISOString().slice(0, 10));
}
const totalIssues = issues.length;
// 3⃣ Calcular PV como curva S teórica
for (const dateStr of dates) {
const current = new Date(dateStr);
const progress =
(current.getTime() - minDate.getTime()) /
(maxDate.getTime() - minDate.getTime());
// Curva sigmoide (forma de S)
const sCurve = 1 / (1 + Math.exp(-12 * (progress - 0.36)));
const pv = sCurve * presupuesto;
pvValues.push(pv);
}
for (const dateStr of dates) {
const current = new Date(dateStr);
const evCount = issues.filter((it) => {
const due = parseDate(it.due_date);
return due && due <= current;
}).length;
const pv = (evCount / totalIssues) * presupuesto;
evValues.push(pv);
}
}
let canvas: HTMLCanvasElement;
onMount(() => {
if (!canvas) return;
const cfg: ChartConfiguration = {
type: "line",
data: {
labels: dates,
datasets: [
{
label: "PV (Valor planificado)",
data: pvValues,
borderColor: "rgba(0, 123, 255, 1)",
backgroundColor: "rgba(0, 123, 255, 0.1)",
fill: false,
tension: 0.3,
},
{
label: "EV (Valor ganado)",
data: evValues,
borderColor: "rgba(40, 167, 69, 1)",
backgroundColor: "rgba(40, 167, 69, 0.1)",
fill: false,
tension: 0.3,
},
],
},
options: {
plugins: {
title: {
display: true,
text: "Indice de control",
font: { size: 16 },
},
},
scales: {
x: {
title: { display: true, text: "Fecha", color: "#ccc" },
},
y: {
title: {
display: true,
text: "Valor acumulado (USD)",
},
},
},
},
};
new Chart(canvas, cfg);
});
async function downloadChart() {
const el = canvas?.parentElement;
if (!el) return;
const img = await html2canvas(el);
const url = img.toDataURL("image/png");
const a = document.createElement("a");
a.href = url;
a.download = "curva-s.png";
a.click();
}
</script>
<div class="chart-container">
<canvas bind:this={canvas}></canvas>
</div>
<button class="btn btn-primary" onclick={downloadChart}>Descargar PNG</button>
<style>
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { Chart, type ChartConfiguration } from "chart.js";
import html2canvas from "html2canvas";
import { onMount } from "svelte";
let canvas: HTMLCanvasElement;
let chartConfig: ChartConfiguration = {
type: "line",
options: {
plugins: {
title: {
display: true,
text: `Presupuesto total`,
font: { size: 14 },
},
},
scales: {
y: {
ticks: { color: "#ccc" },
grid: { color: "#333" },
title: {
display: true,
text: "USD acumulado",
color: "#ccc",
},
},
},
},
data: {
labels: [
"2024-9",
"2024-10",
"2024-11",
"2024-12",
"2025-1",
"2025-2",
"2025-3",
"2025-4",
"2025-5",
"2025-6",
"2025-7",
"2025-8",
],
datasets: [
{
label: "PV",
data: [
2000, 5000, 10000, 15000, 22000, 28000, 34000, 38000,
41000, 41980, 41980, 41980,
],
},
{
label: "EV",
data: [
1500, 4500, 9000, 14000, 20000, 27000, 33000, 37000,
41000, 41980, 41980, 41980,
],
},
],
},
};
onMount(() => {
if (canvas) {
new Chart(canvas, chartConfig);
}
});
</script>
<div style="height: 800px;">
<canvas bind:this={canvas}></canvas>
</div>
<button
onclick={async () => {
const element = canvas?.parentElement;
if (element) {
const canvasImg = await html2canvas(element);
const image = canvasImg.toDataURL("image/png");
const link = document.createElement("a");
link.download = "grafico-indice-control.png";
link.href = image;
link.click();
}
}}
class="btn btn-primary mt-3">Descargar Gráfico como PNG</button
>

View File

@@ -14,6 +14,9 @@
<button class="btn btn-primary" onclick={() => (pagina = 1)}
>Dias de duracion</button
>
<button class="btn btn-primary" onclick={() => (pagina = 2)}
>Indice de Control</button
>
<button
class="btn"
aria-label="Cambiar tema"

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import type { Issue } from "../../types";
import { onMount } from "svelte";
import { Chart, type ChartConfiguration } from "chart.js/auto";
import html2canvas from "html2canvas";
import GraficoNefasto from "../GraficoNefasto.svelte";
import CurvaS from "../CurvaS.svelte";
let { issues }: { issues: Issue[] } = $props();
</script>
<div class="container-fluid">
<div class="accordion" id="graficoAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button
class="accordion-button collapsed"
aria-expanded="false"
data-bs-toggle="collapse"
data-bs-target="#graficoCollapse"
>
Gráfico de Índice de Control
</button>
</h2>
<div id="graficoCollapse" class="accordion-collapse collapse">
<div class="accordion-body">
<GraficoNefasto />
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button
class="accordion-button"
aria-expanded="true"
data-bs-toggle="collapse"
data-bs-target="#graficoCollapse2"
>
Gráfico de Índice de Control
</button>
</h2>
<div id="graficoCollapse2" class="accordion-collapse collapse show">
<div class="accordion-body">
<CurvaS {issues} />
</div>
</div>
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { mount } from "svelte";
import App from "./App.svelte";
//import "./app.css";
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap/dist/js/bootstrap.bundle.min.js";
const app = mount(App, {
target: document.getElementById("app")!,