estela 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -0
- package/dist/billing/amortize.d.ts +44 -0
- package/dist/billing/amortize.d.ts.map +1 -0
- package/dist/billing/amortize.js +83 -0
- package/dist/billing/amortize.js.map +1 -0
- package/dist/billing/amortize.test.d.ts +2 -0
- package/dist/billing/amortize.test.d.ts.map +1 -0
- package/dist/billing/amortize.test.js +121 -0
- package/dist/billing/amortize.test.js.map +1 -0
- package/dist/billing/format.test.d.ts +2 -0
- package/dist/billing/format.test.d.ts.map +1 -0
- package/dist/billing/format.test.js +110 -0
- package/dist/billing/format.test.js.map +1 -0
- package/dist/billing/invoice.d.ts +52 -0
- package/dist/billing/invoice.d.ts.map +1 -0
- package/dist/billing/invoice.js +148 -0
- package/dist/billing/invoice.js.map +1 -0
- package/dist/billing/invoice.test.d.ts +2 -0
- package/dist/billing/invoice.test.d.ts.map +1 -0
- package/dist/billing/invoice.test.js +200 -0
- package/dist/billing/invoice.test.js.map +1 -0
- package/dist/billing/sessionize.d.ts +104 -0
- package/dist/billing/sessionize.d.ts.map +1 -0
- package/dist/billing/sessionize.js +0 -0
- package/dist/billing/sessionize.js.map +1 -0
- package/dist/billing/sessionize.test.d.ts +2 -0
- package/dist/billing/sessionize.test.d.ts.map +1 -0
- package/dist/billing/sessionize.test.js +67 -0
- package/dist/billing/sessionize.test.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1091 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud/auth.d.ts +4 -0
- package/dist/cloud/auth.d.ts.map +1 -0
- package/dist/cloud/auth.js +98 -0
- package/dist/cloud/auth.js.map +1 -0
- package/dist/cloud/client.d.ts +12 -0
- package/dist/cloud/client.d.ts.map +1 -0
- package/dist/cloud/client.js +34 -0
- package/dist/cloud/client.js.map +1 -0
- package/dist/db/schema.d.ts +11 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +424 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/schema.test.d.ts +2 -0
- package/dist/db/schema.test.d.ts.map +1 -0
- package/dist/db/schema.test.js +335 -0
- package/dist/db/schema.test.js.map +1 -0
- package/dist/db/store.d.ts +113 -0
- package/dist/db/store.d.ts.map +1 -0
- package/dist/db/store.js +550 -0
- package/dist/db/store.js.map +1 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +229 -0
- package/dist/doctor.js.map +1 -0
- package/dist/export/csv.d.ts +9 -0
- package/dist/export/csv.d.ts.map +1 -0
- package/dist/export/csv.js +81 -0
- package/dist/export/csv.js.map +1 -0
- package/dist/export/invoice-pdf.d.ts +21 -0
- package/dist/export/invoice-pdf.d.ts.map +1 -0
- package/dist/export/invoice-pdf.js +135 -0
- package/dist/export/invoice-pdf.js.map +1 -0
- package/dist/export/panel.d.ts +78 -0
- package/dist/export/panel.d.ts.map +1 -0
- package/dist/export/panel.js +693 -0
- package/dist/export/panel.js.map +1 -0
- package/dist/export/panel.test.d.ts +2 -0
- package/dist/export/panel.test.d.ts.map +1 -0
- package/dist/export/panel.test.js +273 -0
- package/dist/export/panel.test.js.map +1 -0
- package/dist/export/pdf.d.ts +32 -0
- package/dist/export/pdf.d.ts.map +1 -0
- package/dist/export/pdf.js +229 -0
- package/dist/export/pdf.js.map +1 -0
- package/dist/export/pdf.test.d.ts +2 -0
- package/dist/export/pdf.test.d.ts.map +1 -0
- package/dist/export/pdf.test.js +151 -0
- package/dist/export/pdf.test.js.map +1 -0
- package/dist/export/share.d.ts +42 -0
- package/dist/export/share.d.ts.map +1 -0
- package/dist/export/share.js +181 -0
- package/dist/export/share.js.map +1 -0
- package/dist/export/share.test.d.ts +2 -0
- package/dist/export/share.test.d.ts.map +1 -0
- package/dist/export/share.test.js +116 -0
- package/dist/export/share.test.js.map +1 -0
- package/dist/metrics/index.d.ts +101 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +221 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/metrics.test.d.ts +2 -0
- package/dist/metrics/metrics.test.d.ts.map +1 -0
- package/dist/metrics/metrics.test.js +162 -0
- package/dist/metrics/metrics.test.js.map +1 -0
- package/dist/metrics/people.d.ts +46 -0
- package/dist/metrics/people.d.ts.map +1 -0
- package/dist/metrics/people.js +126 -0
- package/dist/metrics/people.js.map +1 -0
- package/dist/metrics/people.test.d.ts +2 -0
- package/dist/metrics/people.test.d.ts.map +1 -0
- package/dist/metrics/people.test.js +84 -0
- package/dist/metrics/people.test.js.map +1 -0
- package/dist/metrics/team.d.ts +41 -0
- package/dist/metrics/team.d.ts.map +1 -0
- package/dist/metrics/team.js +0 -0
- package/dist/metrics/team.js.map +1 -0
- package/dist/metrics/team.test.d.ts +2 -0
- package/dist/metrics/team.test.d.ts.map +1 -0
- package/dist/metrics/team.test.js +90 -0
- package/dist/metrics/team.test.js.map +1 -0
- package/dist/pricing/catalog.d.ts +39 -0
- package/dist/pricing/catalog.d.ts.map +1 -0
- package/dist/pricing/catalog.js +59 -0
- package/dist/pricing/catalog.js.map +1 -0
- package/dist/pricing/cost.d.ts +25 -0
- package/dist/pricing/cost.d.ts.map +1 -0
- package/dist/pricing/cost.js +50 -0
- package/dist/pricing/cost.js.map +1 -0
- package/dist/pricing/cost.test.d.ts +2 -0
- package/dist/pricing/cost.test.d.ts.map +1 -0
- package/dist/pricing/cost.test.js +74 -0
- package/dist/pricing/cost.test.js.map +1 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1141 -0
- package/dist/server.js.map +1 -0
- package/dist/setup.d.ts +70 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +223 -0
- package/dist/setup.js.map +1 -0
- package/dist/setup.test.d.ts +2 -0
- package/dist/setup.test.d.ts.map +1 -0
- package/dist/setup.test.js +131 -0
- package/dist/setup.test.js.map +1 -0
- package/dist/watchers/claude.d.ts +34 -0
- package/dist/watchers/claude.d.ts.map +1 -0
- package/dist/watchers/claude.js +235 -0
- package/dist/watchers/claude.js.map +1 -0
- package/dist/watchers/claude.test.d.ts +2 -0
- package/dist/watchers/claude.test.d.ts.map +1 -0
- package/dist/watchers/claude.test.js +180 -0
- package/dist/watchers/claude.test.js.map +1 -0
- package/dist/watchers/git.d.ts +42 -0
- package/dist/watchers/git.d.ts.map +1 -0
- package/dist/watchers/git.js +218 -0
- package/dist/watchers/git.js.map +1 -0
- package/dist/watchers/git.test.d.ts +2 -0
- package/dist/watchers/git.test.d.ts.map +1 -0
- package/dist/watchers/git.test.js +73 -0
- package/dist/watchers/git.test.js.map +1 -0
- package/dist/watchers/identity.d.ts +34 -0
- package/dist/watchers/identity.d.ts.map +1 -0
- package/dist/watchers/identity.js +85 -0
- package/dist/watchers/identity.js.map +1 -0
- package/dist/watchers/identity.test.d.ts +2 -0
- package/dist/watchers/identity.test.d.ts.map +1 -0
- package/dist/watchers/identity.test.js +48 -0
- package/dist/watchers/identity.test.js.map +1 -0
- package/dist/web-ui.test.d.ts +2 -0
- package/dist/web-ui.test.d.ts.map +1 -0
- package/dist/web-ui.test.js +312 -0
- package/dist/web-ui.test.js.map +1 -0
- package/node_modules/@estela/shared/dist/index.d.ts +3 -0
- package/node_modules/@estela/shared/dist/index.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/index.js +19 -0
- package/node_modules/@estela/shared/dist/index.js.map +1 -0
- package/node_modules/@estela/shared/dist/money.d.ts +71 -0
- package/node_modules/@estela/shared/dist/money.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/money.js +171 -0
- package/node_modules/@estela/shared/dist/money.js.map +1 -0
- package/node_modules/@estela/shared/dist/types.d.ts +365 -0
- package/node_modules/@estela/shared/dist/types.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/types.js +24 -0
- package/node_modules/@estela/shared/dist/types.js.map +1 -0
- package/node_modules/@estela/shared/package.json +5 -0
- package/package.json +41 -0
- package/web/app.css +749 -0
- package/web/app.js +1257 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/favicon.png +0 -0
- package/web/index.html +132 -0
- package/web/lib.js +94 -0
- package/web/logo-mark.png +0 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/* Estela — dashboard local.
|
|
4
|
+
Sin framework ni paso de compilación: es una página que lee tu propia
|
|
5
|
+
base de datos a través del daemon en 127.0.0.1. */
|
|
6
|
+
|
|
7
|
+
const $ = (sel) => document.querySelector(sel);
|
|
8
|
+
|
|
9
|
+
const state = {
|
|
10
|
+
view: "summary",
|
|
11
|
+
date: localToday(),
|
|
12
|
+
day: null,
|
|
13
|
+
summary: null,
|
|
14
|
+
period: "month",
|
|
15
|
+
open: new Set(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ── Formato ────────────────────────────────────────────────────────────
|
|
19
|
+
// Las funciones puras viven en lib.js para poder probarlas sin navegador.
|
|
20
|
+
|
|
21
|
+
const KINDS = [
|
|
22
|
+
["development", "Desarrollo"], ["meeting", "Reunión"], ["research", "Investigación"],
|
|
23
|
+
["review", "Revisión"], ["travel", "Desplazamiento"], ["support", "Soporte"],
|
|
24
|
+
["other", "Otro"],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function fmtTime(iso) {
|
|
28
|
+
return new Date(iso).toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Red ────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
async function api(path, options) {
|
|
34
|
+
const res = await fetch(path, {
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
...options,
|
|
37
|
+
});
|
|
38
|
+
const body = await res.json();
|
|
39
|
+
if (!res.ok) throw new Error(body.error || `Error ${res.status}`);
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let toastTimer;
|
|
44
|
+
function toast(message) {
|
|
45
|
+
const el = $("#toast");
|
|
46
|
+
el.textContent = message;
|
|
47
|
+
el.classList.add("is-on");
|
|
48
|
+
clearTimeout(toastTimer);
|
|
49
|
+
toastTimer = setTimeout(() => el.classList.remove("is-on"), 2600);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Mi día ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async function loadDay() {
|
|
55
|
+
state.day = await api(`/api/day?date=${state.date}`);
|
|
56
|
+
renderDay();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderDay() {
|
|
60
|
+
const day = state.day;
|
|
61
|
+
const today = localToday();
|
|
62
|
+
|
|
63
|
+
$("#day-title").textContent = fmtDayTitle(state.date);
|
|
64
|
+
$("#go-today").hidden = state.date === today;
|
|
65
|
+
$("#next-day").disabled = state.date >= today;
|
|
66
|
+
|
|
67
|
+
const hasEntries = day.entries.length > 0;
|
|
68
|
+
$("#hero").hidden = !hasEntries;
|
|
69
|
+
|
|
70
|
+
if (hasEntries) {
|
|
71
|
+
$("#day-hours").textContent = fmtDuration(day.totalSeconds);
|
|
72
|
+
$("#day-blocks").textContent =
|
|
73
|
+
day.entries.length === 1 ? "en 1 bloque" : `en ${day.entries.length} bloques`;
|
|
74
|
+
|
|
75
|
+
// Con varias monedas se enseñan todas. Decir "sin tarifa" cuando las hay
|
|
76
|
+
// manda a revisar una configuración que está bien.
|
|
77
|
+
const totals = day.totals || [];
|
|
78
|
+
const money = totals.length
|
|
79
|
+
? totals.map((t) => fmtMoney(t.amountMinor, t.currency)).join(" + ")
|
|
80
|
+
: null;
|
|
81
|
+
|
|
82
|
+
$("#day-amount").textContent = money || "—";
|
|
83
|
+
$("#day-amount").parentElement.querySelector(".hero-label").textContent =
|
|
84
|
+
totals.length > 1 ? "por facturar (por moneda)"
|
|
85
|
+
: totals.length === 1 ? "por facturar"
|
|
86
|
+
: day.missingRate ? "sin tarifa definida"
|
|
87
|
+
: "sin trabajo facturable";
|
|
88
|
+
} else {
|
|
89
|
+
// Vaciar aunque esté oculto: una cifra vieja esperando en el DOM es un
|
|
90
|
+
// número equivocado a la espera de que algo la muestre por accidente.
|
|
91
|
+
$("#day-hours").textContent = "—";
|
|
92
|
+
$("#day-amount").textContent = "—";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
$("#day-body").innerHTML =
|
|
96
|
+
(hasEntries ? renderRows(day.entries) : renderEmpty()) + renderAddForm(day);
|
|
97
|
+
wireRows();
|
|
98
|
+
wireAddForm();
|
|
99
|
+
|
|
100
|
+
const pending = day.pendingApproval;
|
|
101
|
+
$("#approve-bar").hidden = !hasEntries || pending === 0;
|
|
102
|
+
if (pending > 0) {
|
|
103
|
+
$("#approve-text").textContent = pending === 1
|
|
104
|
+
? "Queda 1 bloque por revisar — llévame a él"
|
|
105
|
+
: `Quedan ${pending} bloques por revisar — llévame al primero`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderEmpty() {
|
|
110
|
+
const today = localToday();
|
|
111
|
+
if (state.date === today) {
|
|
112
|
+
return `<div class="empty">
|
|
113
|
+
<h2>Todavía nada por aquí</h2>
|
|
114
|
+
<p>En cuanto trabajes con un agente en un repositorio configurado, aparecerá solo.
|
|
115
|
+
Si ya trabajaste, trae lo de hoy:</p>
|
|
116
|
+
<code>estela import</code>
|
|
117
|
+
</div>`;
|
|
118
|
+
}
|
|
119
|
+
return `<div class="empty">
|
|
120
|
+
<h2>Sin actividad este día</h2>
|
|
121
|
+
<p>No hay ningún bloque de trabajo registrado.</p>
|
|
122
|
+
</div>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderRows(entries) {
|
|
126
|
+
return `<div class="rows">${entries.map(renderRow).join("")}</div>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderRow(entry) {
|
|
130
|
+
const open = state.open.has(entry.id);
|
|
131
|
+
const money = fmtMoney(entry.amountMinor, entry.currency);
|
|
132
|
+
|
|
133
|
+
const classes = ["row"];
|
|
134
|
+
if (open) classes.push("is-open");
|
|
135
|
+
if (entry.approved) classes.push("is-approved");
|
|
136
|
+
if (!entry.billable) classes.push("not-billable");
|
|
137
|
+
if (entry.invoiced) classes.push("is-invoiced");
|
|
138
|
+
|
|
139
|
+
const KIND_LABEL = Object.fromEntries(KINDS);
|
|
140
|
+
const kindTag = entry.kind && entry.kind !== "development"
|
|
141
|
+
? `<span class="kindtag">${esc(KIND_LABEL[entry.kind] || entry.kind)}</span>` : "";
|
|
142
|
+
|
|
143
|
+
const meta = [
|
|
144
|
+
entry.projectName,
|
|
145
|
+
entry.clientName,
|
|
146
|
+
entry.source === "manual" ? "añadido a mano"
|
|
147
|
+
: entry.source === "commit"
|
|
148
|
+
? `${fmtTime(entry.startedAt)}–${fmtTime(entry.endedAt)} · deducido de commits`
|
|
149
|
+
: `${fmtTime(entry.startedAt)}–${fmtTime(entry.endedAt)}`,
|
|
150
|
+
].filter(Boolean).join(" · ");
|
|
151
|
+
|
|
152
|
+
return `
|
|
153
|
+
<article class="${classes.join(" ")}" data-id="${esc(entry.id)}">
|
|
154
|
+
<button type="button" class="row-head" data-toggle aria-expanded="${open}">
|
|
155
|
+
<span class="check" aria-hidden="true">✓</span>
|
|
156
|
+
<span class="row-text">
|
|
157
|
+
<span class="row-title">${kindTag}${esc(entry.description)}</span>
|
|
158
|
+
<span class="row-meta">${esc(meta)}</span>
|
|
159
|
+
</span>
|
|
160
|
+
<span class="row-figs">
|
|
161
|
+
<span class="row-time">${fmtDuration(entry.seconds)}</span>
|
|
162
|
+
<span class="row-money ${money ? "" : "none"}">${money || (entry.billable ? "sin tarifa" : "no facturable")}</span>
|
|
163
|
+
</span>
|
|
164
|
+
<span class="chev" aria-hidden="true">›</span>
|
|
165
|
+
</button>
|
|
166
|
+
${open ? renderRowBody(entry) : ""}
|
|
167
|
+
</article>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderRowBody(entry) {
|
|
171
|
+
const projects = (state.day.projects || [])
|
|
172
|
+
.map((p) => `<option value="${esc(p.id)}"${p.id === entry.projectId ? " selected" : ""}>${esc(p.name)}</option>`)
|
|
173
|
+
.join("");
|
|
174
|
+
|
|
175
|
+
const commits = entry.commits.length
|
|
176
|
+
? `<div class="commits">${entry.commits.map((c) =>
|
|
177
|
+
`<div class="commit"><code>${esc(c.hash)}</code><span>${esc(c.subject)}</span></div>`).join("")}</div>`
|
|
178
|
+
: "";
|
|
179
|
+
|
|
180
|
+
const ai = entry.aiMicroUsd > 0
|
|
181
|
+
? `<p class="detail-line">Consumo de IA: <b>$${(entry.aiMicroUsd / 1e6).toFixed(2)}</b> en tarifa API equivalente.</p>`
|
|
182
|
+
: "";
|
|
183
|
+
|
|
184
|
+
if (entry.invoiced) {
|
|
185
|
+
return `<div class="row-body">
|
|
186
|
+
<p class="detail-line">Este bloque ya está facturado, así que no se puede editar.</p>
|
|
187
|
+
${commits}${ai}
|
|
188
|
+
</div>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return `
|
|
192
|
+
<div class="row-body">
|
|
193
|
+
<div class="field">
|
|
194
|
+
<label for="d-${esc(entry.id)}">Qué hiciste</label>
|
|
195
|
+
<input id="d-${esc(entry.id)}" type="text" value="${esc(entry.description)}" data-field="description">
|
|
196
|
+
</div>
|
|
197
|
+
<div class="field-pair">
|
|
198
|
+
<div class="field">
|
|
199
|
+
<label for="p-${esc(entry.id)}">Proyecto</label>
|
|
200
|
+
<select id="p-${esc(entry.id)}" data-field="projectId">${projects}</select>
|
|
201
|
+
</div>
|
|
202
|
+
<div class="field">
|
|
203
|
+
<label for="m-${esc(entry.id)}">Minutos</label>
|
|
204
|
+
<input id="m-${esc(entry.id)}" type="number" min="0" step="1"
|
|
205
|
+
value="${Math.round(entry.seconds / 60)}" data-field="minutes">
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
${commits}${ai}
|
|
209
|
+
<div class="row-actions">
|
|
210
|
+
<button type="button" class="chip ${entry.billable ? "is-on" : ""}" data-billable>
|
|
211
|
+
${entry.billable ? "Facturable" : "No facturable"}
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* Registro de horas que no dejan rastro en Git.
|
|
218
|
+
Sin esto el parte del día miente por omisión: enseña solo lo que tocó el
|
|
219
|
+
repositorio y hace parecer que las reuniones y los viajes no ocurrieron. */
|
|
220
|
+
|
|
221
|
+
function renderAddForm(day) {
|
|
222
|
+
if (!day.projects.length) return "";
|
|
223
|
+
|
|
224
|
+
const projects = day.projects
|
|
225
|
+
.map((p) => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join("");
|
|
226
|
+
const kinds = KINDS
|
|
227
|
+
.map(([v, l]) => `<option value="${v}">${l}</option>`).join("");
|
|
228
|
+
|
|
229
|
+
return `
|
|
230
|
+
<div class="addwrap">
|
|
231
|
+
<button type="button" class="add-toggle" id="add-toggle">+ Añadir horas sin código</button>
|
|
232
|
+
<form class="addform" id="add-form" hidden>
|
|
233
|
+
<p class="addform-hint">Desarrollo sin agente, reuniones, desplazamientos, investigación.
|
|
234
|
+
Un import nunca las toca.</p>
|
|
235
|
+
<div class="addrow">
|
|
236
|
+
<label class="field">
|
|
237
|
+
<span>Tipo</span>
|
|
238
|
+
<select id="add-kind">${kinds}</select>
|
|
239
|
+
</label>
|
|
240
|
+
<label class="field">
|
|
241
|
+
<span>Minutos</span>
|
|
242
|
+
<input id="add-minutes" type="number" min="5" step="5" value="60" required>
|
|
243
|
+
</label>
|
|
244
|
+
</div>
|
|
245
|
+
<label class="field">
|
|
246
|
+
<span>Qué hiciste</span>
|
|
247
|
+
<input id="add-what" type="text" placeholder="Seguimiento semanal con el cliente">
|
|
248
|
+
</label>
|
|
249
|
+
<label class="field">
|
|
250
|
+
<span>Proyecto</span>
|
|
251
|
+
<select id="add-project">${projects}</select>
|
|
252
|
+
</label>
|
|
253
|
+
<div class="addactions">
|
|
254
|
+
<button type="button" class="chip" id="add-cancel">Cancelar</button>
|
|
255
|
+
<button type="submit" class="btn-main">Añadir</button>
|
|
256
|
+
</div>
|
|
257
|
+
</form>
|
|
258
|
+
</div>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function wireAddForm() {
|
|
262
|
+
const toggle = $("#add-toggle");
|
|
263
|
+
if (!toggle) return;
|
|
264
|
+
const form = $("#add-form");
|
|
265
|
+
|
|
266
|
+
const open = (yes) => {
|
|
267
|
+
form.hidden = !yes;
|
|
268
|
+
toggle.hidden = yes;
|
|
269
|
+
if (yes) $("#add-what").focus();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
toggle.addEventListener("click", () => open(true));
|
|
273
|
+
$("#add-cancel").addEventListener("click", () => open(false));
|
|
274
|
+
|
|
275
|
+
form.addEventListener("submit", async (e) => {
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
try {
|
|
278
|
+
await api("/api/entry", {
|
|
279
|
+
method: "POST",
|
|
280
|
+
body: JSON.stringify({
|
|
281
|
+
projectId: $("#add-project").value,
|
|
282
|
+
minutes: Number($("#add-minutes").value),
|
|
283
|
+
kind: $("#add-kind").value,
|
|
284
|
+
description: $("#add-what").value,
|
|
285
|
+
date: state.date,
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
await loadDay();
|
|
289
|
+
toast("Horas añadidas");
|
|
290
|
+
} catch (error) {
|
|
291
|
+
toast(error.message);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function wireRows() {
|
|
297
|
+
document.querySelectorAll(".row").forEach((row) => {
|
|
298
|
+
const id = row.dataset.id;
|
|
299
|
+
|
|
300
|
+
row.querySelector("[data-toggle]").addEventListener("click", () => {
|
|
301
|
+
if (state.open.has(id)) state.open.delete(id);
|
|
302
|
+
else state.open.add(id);
|
|
303
|
+
renderDay();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
row.querySelectorAll("[data-field]").forEach((input) => {
|
|
307
|
+
input.addEventListener("change", async () => {
|
|
308
|
+
const field = input.dataset.field;
|
|
309
|
+
const patch = field === "minutes"
|
|
310
|
+
? { seconds: Math.max(0, Math.round(Number(input.value) * 60)) }
|
|
311
|
+
: { [field]: input.value };
|
|
312
|
+
await save(id, patch);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const billableBtn = row.querySelector("[data-billable]");
|
|
317
|
+
if (billableBtn) {
|
|
318
|
+
billableBtn.addEventListener("click", async () => {
|
|
319
|
+
const entry = state.day.entries.find((e) => e.id === id);
|
|
320
|
+
await save(id, { billable: !entry.billable });
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function save(id, patch) {
|
|
327
|
+
try {
|
|
328
|
+
await api(`/api/entry/${encodeURIComponent(id)}`, {
|
|
329
|
+
method: "PATCH",
|
|
330
|
+
body: JSON.stringify(patch),
|
|
331
|
+
});
|
|
332
|
+
await loadDay();
|
|
333
|
+
toast("Guardado");
|
|
334
|
+
} catch (error) {
|
|
335
|
+
toast(error.message);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Resumen: todos los proyectos a la vez ──────────────────────────────
|
|
340
|
+
|
|
341
|
+
async function loadSummary() {
|
|
342
|
+
const { from, to } = periodRange(state.period === "all" ? "quarter" : state.period);
|
|
343
|
+
state.summary = await api(`/api/summary?from=${from}&to=${to}`);
|
|
344
|
+
renderSummary();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function renderSummary() {
|
|
348
|
+
const s = state.summary;
|
|
349
|
+
$("#summary-range").textContent =
|
|
350
|
+
`${s.activeProjects} proyecto${s.activeProjects === 1 ? "" : "s"} con actividad · ` +
|
|
351
|
+
`${s.activeDays} día${s.activeDays === 1 ? "" : "s"} trabajados`;
|
|
352
|
+
|
|
353
|
+
// Con varias monedas se enseñan todas: un guion parecería que falta un dato.
|
|
354
|
+
const money = (s.totals || []).length
|
|
355
|
+
? s.totals.map((t) => fmtMoney(t.amountMinor, t.currency)).join(" + ")
|
|
356
|
+
: null;
|
|
357
|
+
const moneyLabel = (s.totals || []).length > 1
|
|
358
|
+
? "valor del trabajo (por moneda)" : "valor del trabajo";
|
|
359
|
+
|
|
360
|
+
$("#kpis").innerHTML = `
|
|
361
|
+
${kpi(fmtDuration(s.totalSeconds), "horas registradas")}
|
|
362
|
+
${kpi(money || "—", moneyLabel, "kpi-money")}
|
|
363
|
+
${s.billableSeconds !== s.totalSeconds
|
|
364
|
+
? kpi(fmtDuration(s.billableSeconds), "de ellas facturables", "kpi-quiet") : ""}
|
|
365
|
+
${kpi(String(s.pendingApproval), s.pendingApproval === 1 ? "bloque por revisar" : "bloques por revisar",
|
|
366
|
+
s.pendingApproval > 0 ? "kpi-attention" : "")}
|
|
367
|
+
${kpi("$" + (s.aiMicroUsd / 1e6).toFixed(2), "consumo de IA", "kpi-quiet")}`;
|
|
368
|
+
|
|
369
|
+
const parts = [];
|
|
370
|
+
|
|
371
|
+
if (s.projects.length === 0) {
|
|
372
|
+
parts.push(`<div class="empty">
|
|
373
|
+
<h2>Sin actividad en este periodo</h2>
|
|
374
|
+
<p>Prueba con un rango más amplio, o comprueba que tus repositorios estén
|
|
375
|
+
asignados a un proyecto.</p>
|
|
376
|
+
<code>estela status</code>
|
|
377
|
+
</div>`);
|
|
378
|
+
} else {
|
|
379
|
+
if (s.pendingApproval > 0) {
|
|
380
|
+
parts.push(`<div class="approve-all">
|
|
381
|
+
<p>Hay <b>${s.pendingApproval}</b> bloques sin revisar en este periodo.
|
|
382
|
+
Al configurar un proyecto entra de golpe todo su histórico.</p>
|
|
383
|
+
<button type="button" class="btn-main" id="approve-all">Aprobar el periodo</button>
|
|
384
|
+
</div>`);
|
|
385
|
+
}
|
|
386
|
+
parts.push(renderSparkline(s.byDay));
|
|
387
|
+
parts.push(`<h2 class="block-title">Proyectos</h2>`);
|
|
388
|
+
parts.push(`<div class="plist">${s.projects.map(renderProjectRow).join("")}</div>`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (s.activity.length) {
|
|
392
|
+
parts.push(`<h2 class="block-title">Actividad reciente</h2>`);
|
|
393
|
+
parts.push(`<div class="feed">${s.activity.map(renderActivity).join("")}</div>`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
$("#summary-body").innerHTML = parts.join("");
|
|
397
|
+
|
|
398
|
+
$("#approve-all")?.addEventListener("click", async () => {
|
|
399
|
+
const { from, to } = periodRange(state.period === "all" ? "quarter" : state.period);
|
|
400
|
+
try {
|
|
401
|
+
const r = await api("/api/approve-range", {
|
|
402
|
+
method: "POST", body: JSON.stringify({ from, to }),
|
|
403
|
+
});
|
|
404
|
+
await loadSummary();
|
|
405
|
+
toast(`${r.approved} bloques aprobados`);
|
|
406
|
+
} catch (error) { toast(error.message); }
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function kpi(value, label, extra = "") {
|
|
411
|
+
return `<div class="kpi ${extra}">
|
|
412
|
+
<div class="kpi-value">${esc(value)}</div>
|
|
413
|
+
<div class="kpi-label">${esc(label)}</div>
|
|
414
|
+
</div>`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/* Barras por día. Da el pulso del periodo de un vistazo: dónde hubo trabajo y
|
|
418
|
+
dónde no, que es lo primero que mira quien lleva varios proyectos. */
|
|
419
|
+
function renderSparkline(byDay) {
|
|
420
|
+
if (byDay.length < 2) return "";
|
|
421
|
+
const max = Math.max(...byDay.map((d) => d.seconds));
|
|
422
|
+
const bars = byDay.map((d) => {
|
|
423
|
+
const pct = Math.max(4, Math.round((d.seconds / max) * 100));
|
|
424
|
+
const label = `${d.date}: ${fmtDuration(d.seconds)}`;
|
|
425
|
+
return `<div class="bar" style="height:${pct}%" title="${esc(label)}"></div>`;
|
|
426
|
+
}).join("");
|
|
427
|
+
return `<div class="chart"><div class="chart-bars">${bars}</div>
|
|
428
|
+
<div class="chart-axis"><span>${esc(byDay[0].date.slice(5))}</span>
|
|
429
|
+
<span>${esc(byDay[byDay.length - 1].date.slice(5))}</span></div></div>`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function renderProjectRow(p) {
|
|
433
|
+
const amount = fmtMoney(p.amountMinor, p.currency);
|
|
434
|
+
// En nómina no se enseña tarifa: no existe, y un hueco invita a rellenarlo.
|
|
435
|
+
const rate = p.kind === "client" ? fmtMoney(p.hourlyMinor, p.currency) : null;
|
|
436
|
+
const flag = p.pendingApproval > 0
|
|
437
|
+
? `<span class="tag tag-warn">${p.pendingApproval} por revisar</span>` : "";
|
|
438
|
+
const KIND_TAG = { employment: "nómina", internal: "interno" };
|
|
439
|
+
const notBillable = KIND_TAG[p.kind] ? `<span class="tag">${KIND_TAG[p.kind]}</span>` : "";
|
|
440
|
+
|
|
441
|
+
return `
|
|
442
|
+
<div class="prow">
|
|
443
|
+
<div class="prow-text">
|
|
444
|
+
<div class="prow-name">${esc(p.projectName)} ${flag}${notBillable}</div>
|
|
445
|
+
<div class="prow-meta">${esc(p.clientName)}${rate ? ` · ${rate}/h` : ""} ·
|
|
446
|
+
${p.days} día${p.days === 1 ? "" : "s"} · última vez ${esc(p.lastDay)}</div>
|
|
447
|
+
</div>
|
|
448
|
+
<div class="prow-figs">
|
|
449
|
+
<div class="prow-hours">${fmtDuration(p.seconds)}</div>
|
|
450
|
+
<div class="prow-amount">${amount || "—"}</div>
|
|
451
|
+
</div>
|
|
452
|
+
</div>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function renderActivity(c) {
|
|
456
|
+
const when = new Date(c.at).toLocaleDateString("es-ES", { day: "numeric", month: "short" });
|
|
457
|
+
return `
|
|
458
|
+
<div class="act">
|
|
459
|
+
<code>${esc(c.hash)}</code>
|
|
460
|
+
<span class="act-subject">${esc(c.subject)}</span>
|
|
461
|
+
<span class="act-repo">${esc(c.repo)}</span>
|
|
462
|
+
<span class="act-diff">+${c.linesAdded} −${c.linesDeleted}</span>
|
|
463
|
+
<span class="act-when">${esc(when)}</span>
|
|
464
|
+
</div>`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── Equipo ─────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
/* Lo que arregla la dispersión del Resumen: ningún número va solo. Cada KPI
|
|
470
|
+
lleva dentro su comparación, su denominador o su unidad, porque "138h" no
|
|
471
|
+
dice nada y "138h, 11 más que el mes pasado" sí.
|
|
472
|
+
|
|
473
|
+
Y la regla que gobierna la tabla de personas: esta pantalla responde "dónde
|
|
474
|
+
está el trabajo y dónde está el riesgo", nunca "quién rinde más". Por eso se
|
|
475
|
+
ordena por actividad reciente y no por horas, y por eso cada fila dice si sus
|
|
476
|
+
horas están medidas o estimadas. */
|
|
477
|
+
|
|
478
|
+
const TEAM_ROWS = 8;
|
|
479
|
+
|
|
480
|
+
state.teamPeriod = "quarter";
|
|
481
|
+
state.teamAll = false;
|
|
482
|
+
state.teamProject = "";
|
|
483
|
+
|
|
484
|
+
async function loadTeam() {
|
|
485
|
+
const { from, to } = periodRange(state.teamPeriod);
|
|
486
|
+
state.team = await api(
|
|
487
|
+
`/api/team?from=${from}&to=${to}` +
|
|
488
|
+
(state.teamProject ? `&project=${encodeURIComponent(state.teamProject)}` : ""));
|
|
489
|
+
renderTeam();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function renderTeam() {
|
|
493
|
+
const t = state.team;
|
|
494
|
+
const people = t.people.length;
|
|
495
|
+
|
|
496
|
+
$("#team-sub").textContent =
|
|
497
|
+
`${people} persona${people === 1 ? "" : "s"} · ` +
|
|
498
|
+
`${t.activeDays} día${t.activeDays === 1 ? "" : "s"} con actividad`;
|
|
499
|
+
|
|
500
|
+
fillProjectFilter(t.projects);
|
|
501
|
+
$("#team-alerts").innerHTML = renderAlerts(t);
|
|
502
|
+
$("#team-kpis").innerHTML = renderTeamKpis(t);
|
|
503
|
+
|
|
504
|
+
const parts = [];
|
|
505
|
+
if (t.projects.length) {
|
|
506
|
+
parts.push(`<h2 class="block-title">Por proyecto</h2>`);
|
|
507
|
+
parts.push(`<div class="tbl-wrap"><table class="tbl">
|
|
508
|
+
<thead><tr>
|
|
509
|
+
<th>Proyecto</th><th class="th-comp">Composición</th>
|
|
510
|
+
<th class="num">Horas</th><th class="num">Coste IA</th>
|
|
511
|
+
<th class="num">Valor</th><th>Estado</th>
|
|
512
|
+
</tr></thead>
|
|
513
|
+
<tbody>${t.projects.map(teamProjectRow).join("")}</tbody></table></div>`);
|
|
514
|
+
parts.push(`<p class="legend">
|
|
515
|
+
<span class="sw sw-agent"></span> medido con agente
|
|
516
|
+
<span class="sw sw-commit"></span> estimado de commits
|
|
517
|
+
<span class="sw sw-manual"></span> anotado a mano</p>`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (people) {
|
|
521
|
+
// Sin proyecto elegido se mezclan las plantillas de todos tus clientes y la
|
|
522
|
+
// tabla se va a treinta filas, que es volver a la dispersión que esto venía
|
|
523
|
+
// a arreglar. Se enseñan las más recientes y se dice cuántas faltan.
|
|
524
|
+
const shown = state.teamAll ? t.people : t.people.slice(0, TEAM_ROWS);
|
|
525
|
+
const hidden = t.people.length - shown.length;
|
|
526
|
+
|
|
527
|
+
// Las cifras de arriba y esta tabla NO suman lo mismo, y hay que decirlo:
|
|
528
|
+
// los totales son tu trabajo imputado, y aquí hay horas estimadas de gente
|
|
529
|
+
// que no usa Estela. Sin esta nota, el primer lector atento encuentra que
|
|
530
|
+
// los números no cuadran y deja de creerse toda la pantalla.
|
|
531
|
+
parts.push(`<h2 class="block-title">Personas
|
|
532
|
+
<span class="block-note">por actividad reciente</span></h2>`);
|
|
533
|
+
parts.push(`<p class="note-inline">Las horas de tus compañeros se estiman
|
|
534
|
+
desde sus commits y <b>no entran</b> en los totales de arriba, que cuentan
|
|
535
|
+
solo tu trabajo imputado. Si alguien instala Estela, su fila pasa a
|
|
536
|
+
medida.</p>`);
|
|
537
|
+
parts.push(`<div class="tbl-wrap"><table class="tbl">
|
|
538
|
+
<thead><tr>
|
|
539
|
+
<th>Persona</th><th class="num">Horas</th><th>Precisión</th>
|
|
540
|
+
<th>Ramas</th><th class="num">Última vez</th>
|
|
541
|
+
</tr></thead>
|
|
542
|
+
<tbody>${shown.map(personRow).join("")}</tbody></table></div>`);
|
|
543
|
+
|
|
544
|
+
if (hidden > 0) {
|
|
545
|
+
parts.push(`<button type="button" class="more" id="team-more">
|
|
546
|
+
Ver ${hidden} persona${hidden === 1 ? "" : "s"} más</button>`);
|
|
547
|
+
} else if (state.teamAll && t.people.length > TEAM_ROWS) {
|
|
548
|
+
parts.push(`<button type="button" class="more" id="team-more">Ver menos</button>`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!t.projects.length && !people) {
|
|
553
|
+
parts.push(`<div class="empty"><h2>Sin actividad en este periodo</h2>
|
|
554
|
+
<p>Prueba con un rango más amplio.</p></div>`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
$("#team-body").innerHTML = parts.join("");
|
|
558
|
+
|
|
559
|
+
$("#team-more")?.addEventListener("click", () => {
|
|
560
|
+
state.teamAll = !state.teamAll;
|
|
561
|
+
renderTeam();
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/* Avisos accionables, no decorativos: cada uno dice qué pasa y qué hacer. */
|
|
566
|
+
function renderAlerts(t) {
|
|
567
|
+
const out = [];
|
|
568
|
+
|
|
569
|
+
for (const p of t.projects) {
|
|
570
|
+
if (p.budgetPct !== null && p.budgetPct >= 80) {
|
|
571
|
+
const over = p.budgetPct >= 100;
|
|
572
|
+
out.push(alertBox(over ? "danger" : "warn",
|
|
573
|
+
`${p.projectName} ha ${over ? "superado" : "consumido el " + p.budgetPct + "% de"} su presupuesto de IA`,
|
|
574
|
+
`$${(p.aiMicroUsd / 1e6).toFixed(2)} de $${(p.budgetMicroUsd / 1e6).toFixed(2)} en este periodo.`));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const stale = t.openWork.filter((w) => w.ageDays >= 14);
|
|
579
|
+
if (stale.length) {
|
|
580
|
+
out.push(alertBox("warn",
|
|
581
|
+
`${stale.length} rama${stale.length === 1 ? "" : "s"} sin integrar desde hace más de dos semanas`,
|
|
582
|
+
`La más antigua, ${stale[0].name}, lleva ${stale[0].ageDays} días. ` +
|
|
583
|
+
`Puede ser trabajo parado o una revisión pendiente en el otro lado.`));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (t.pendingApproval > 0) {
|
|
587
|
+
out.push(alertBox("info", `${t.pendingApproval} bloques sin revisar`,
|
|
588
|
+
"Hasta revisarlos no deberían salir en un informe."));
|
|
589
|
+
}
|
|
590
|
+
return out.join("");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function alertBox(kind, title, detail) {
|
|
594
|
+
return `<div class="alert alert-${kind}">
|
|
595
|
+
<div class="alert-t">${esc(title)}</div>
|
|
596
|
+
<div class="alert-d">${esc(detail)}</div>
|
|
597
|
+
</div>`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function renderTeamKpis(t) {
|
|
601
|
+
const hours = fmtDuration(t.totalSeconds);
|
|
602
|
+
const delta = t.totalSeconds - t.previousSeconds;
|
|
603
|
+
const deltaTxt = t.previousSeconds > 0
|
|
604
|
+
? `${delta >= 0 ? "+" : "−"}${fmtDuration(Math.abs(delta))} frente al periodo anterior`
|
|
605
|
+
: "sin periodo anterior con el que comparar";
|
|
606
|
+
|
|
607
|
+
const ai = t.aiMicroUsd / 1e6;
|
|
608
|
+
const perHour = t.totalSeconds > 0 ? (ai / (t.totalSeconds / 3600)) : 0;
|
|
609
|
+
|
|
610
|
+
const pct = t.totalSeconds > 0
|
|
611
|
+
? Math.round((t.billableSeconds / t.totalSeconds) * 100) : 0;
|
|
612
|
+
|
|
613
|
+
const open = t.openWork.length;
|
|
614
|
+
const oldest = open ? t.openWork[0] : null;
|
|
615
|
+
|
|
616
|
+
return [
|
|
617
|
+
teamKpi(hours, "horas del periodo", deltaTxt, delta >= 0 ? "up" : "down"),
|
|
618
|
+
teamKpi("$" + ai.toFixed(2), "coste de IA",
|
|
619
|
+
`$${perHour.toFixed(2)} por hora trabajada`),
|
|
620
|
+
teamKpi(pct + "%", "facturable",
|
|
621
|
+
`${fmtDuration(t.billableSeconds)} de ${hours}`),
|
|
622
|
+
teamKpi(String(open), open === 1 ? "rama sin integrar" : "ramas sin integrar",
|
|
623
|
+
oldest ? `la más antigua, ${oldest.ageDays} días` : "todo integrado",
|
|
624
|
+
open > 0 ? "warn" : ""),
|
|
625
|
+
].join("");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function teamKpi(value, label, context, tone = "") {
|
|
629
|
+
return `<div class="kpi kpi-ctx ${tone ? "kpi-" + tone : ""}">
|
|
630
|
+
<div class="kpi-label">${esc(label)}</div>
|
|
631
|
+
<div class="kpi-value">${esc(value)}</div>
|
|
632
|
+
<div class="kpi-ctx-line">${esc(context)}</div>
|
|
633
|
+
</div>`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/* La barra de composición dice de qué está hecha la cifra de horas: cuánto se
|
|
637
|
+
midió con un agente y cuánto se estimó desde los commits. Sin esto, 182h
|
|
638
|
+
medidas y 182h estimadas se ven igual, y no valen lo mismo. */
|
|
639
|
+
function compositionBar(c) {
|
|
640
|
+
const total = c.agent + c.commit + c.manual;
|
|
641
|
+
if (!total) return "";
|
|
642
|
+
const seg = (n, cls) => n > 0
|
|
643
|
+
? `<span class="seg ${cls}" style="width:${(n / total) * 100}%"></span>` : "";
|
|
644
|
+
const title = `agente ${fmtDuration(c.agent)} · commits ${fmtDuration(c.commit)} · a mano ${fmtDuration(c.manual)}`;
|
|
645
|
+
return `<div class="comp" title="${esc(title)}">
|
|
646
|
+
${seg(c.agent, "sw-agent")}${seg(c.commit, "sw-commit")}${seg(c.manual, "sw-manual")}
|
|
647
|
+
</div>`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function teamProjectRow(p) {
|
|
651
|
+
const STATE = {
|
|
652
|
+
employment: ["nómina", "tag"],
|
|
653
|
+
internal: ["interno", "tag"],
|
|
654
|
+
};
|
|
655
|
+
const st = STATE[p.kind] ??
|
|
656
|
+
(p.pendingApproval > 0 ? ["por revisar", "tag tag-warn"] : ["listo", "tag tag-ok"]);
|
|
657
|
+
|
|
658
|
+
return `<tr>
|
|
659
|
+
<td><div class="td-name">${esc(p.projectName)}</div>
|
|
660
|
+
<div class="td-sub">${esc(p.clientName)}</div></td>
|
|
661
|
+
<td class="th-comp">${compositionBar(p.composition)}</td>
|
|
662
|
+
<td class="num">${fmtDuration(p.seconds)}</td>
|
|
663
|
+
<td class="num money-ai">$${(p.aiMicroUsd / 1e6).toFixed(2)}</td>
|
|
664
|
+
<td class="num">${p.amountMinor !== null ? fmtMoney(p.amountMinor, p.currency) : "—"}</td>
|
|
665
|
+
<td><span class="${st[1]}">${esc(st[0])}</span></td>
|
|
666
|
+
</tr>`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function personRow(p) {
|
|
670
|
+
const branches = p.branches.length
|
|
671
|
+
? p.branches.slice(0, 2).map((b) => `<code class="br">${esc(b)}</code>`).join(" ") +
|
|
672
|
+
(p.branches.length > 2 ? ` <span class="td-sub">+${p.branches.length - 2}</span>` : "")
|
|
673
|
+
: `<span class="td-sub">—</span>`;
|
|
674
|
+
|
|
675
|
+
return `<tr>
|
|
676
|
+
<td><div class="td-name">${esc(p.name)}${p.isMe ? ' <span class="tag">tú</span>' : ""}</div>
|
|
677
|
+
<div class="td-sub">${esc(p.emails[0] || "")}</div></td>
|
|
678
|
+
<td class="num">${fmtDuration(p.seconds)}</td>
|
|
679
|
+
<td><span class="prec ${p.measured ? "prec-ok" : ""}">${p.measured ? "medido" : "estimado"}</span></td>
|
|
680
|
+
<td>${branches}</td>
|
|
681
|
+
<td class="num td-sub">${esc(p.lastDay || "—")}</td>
|
|
682
|
+
</tr>`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function fillProjectFilter(projects) {
|
|
686
|
+
const sel = $("#team-project");
|
|
687
|
+
if (sel.dataset.filled === String(projects.length) && sel.value === state.teamProject) return;
|
|
688
|
+
const seen = state.team.projects.map((p) => [p.projectId, p.projectName]);
|
|
689
|
+
sel.innerHTML = `<option value="">Todos los proyectos</option>` +
|
|
690
|
+
seen.map(([id, name]) => `<option value="${esc(id)}">${esc(name)}</option>`).join("");
|
|
691
|
+
sel.value = state.teamProject;
|
|
692
|
+
sel.dataset.filled = String(projects.length);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
$("#team-project").addEventListener("change", (e) => {
|
|
696
|
+
state.teamProject = e.target.value;
|
|
697
|
+
state.teamAll = false;
|
|
698
|
+
loadTeam();
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
document.querySelectorAll("[data-tperiod]").forEach((btn) => {
|
|
702
|
+
btn.addEventListener("click", () => {
|
|
703
|
+
state.teamPeriod = btn.dataset.tperiod;
|
|
704
|
+
document.querySelectorAll("[data-tperiod]").forEach(
|
|
705
|
+
(b) => b.classList.toggle("is-on", b === btn));
|
|
706
|
+
loadTeam();
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// ── Proyectos ──────────────────────────────────────────────────────────
|
|
711
|
+
|
|
712
|
+
async function loadProjects() {
|
|
713
|
+
const [data, orphans] = await Promise.all([
|
|
714
|
+
api("/api/projects"),
|
|
715
|
+
api("/api/unassigned"),
|
|
716
|
+
]);
|
|
717
|
+
state.projects = data;
|
|
718
|
+
renderProjects(data, orphans.repos);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function renderProjects(data, orphans) {
|
|
722
|
+
const parts = [];
|
|
723
|
+
const pubs = new Map((data.publications || []).map((p) => [p.projectId, p]));
|
|
724
|
+
|
|
725
|
+
// Lo que envejece un panel no son los días: es que haya trabajo nuevo desde
|
|
726
|
+
// que se publicó. Uno de hace una semana está al día si no tocaste el
|
|
727
|
+
// proyecto en esa semana.
|
|
728
|
+
const atrasados = (data.publications || []).filter((p) => p.staleBlocks > 0);
|
|
729
|
+
if (atrasados.length) {
|
|
730
|
+
parts.push(`<div class="stale">
|
|
731
|
+
<p><b>${atrasados.length === 1 ? "Un panel compartido está" : atrasados.length + " paneles están"}
|
|
732
|
+
desactualizado${atrasados.length === 1 ? "" : "s"}.</b>
|
|
733
|
+
Tu cliente está viendo datos anteriores a tu último trabajo.</p>
|
|
734
|
+
<ul>${atrasados.map((p) => {
|
|
735
|
+
const nombre = (data.projects.find((x) => x.id === p.projectId) || {}).name || p.projectId;
|
|
736
|
+
return `<li>${esc(nombre)} — ${p.staleBlocks}
|
|
737
|
+
${p.staleBlocks === 1 ? "bloque nuevo" : "bloques nuevos"}
|
|
738
|
+
desde que lo publicaste ${p.daysAgo === 0 ? "hoy" :
|
|
739
|
+
p.daysAgo === 1 ? "ayer" : "hace " + p.daysAgo + " días"}</li>`;
|
|
740
|
+
}).join("")}</ul>
|
|
741
|
+
<p class="stale-fix">Pulsa <b>Compartir con el cliente</b> en cada uno y vuelve a subirlo.</p>
|
|
742
|
+
</div>`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
for (const p of data.projects) {
|
|
746
|
+
// En nómina no se enseña tarifa: no existe, y un hueco invita a rellenarlo.
|
|
747
|
+
const rate = p.kind === "client" ? fmtMoney(p.hourlyMinor, p.currency) : null;
|
|
748
|
+
const history = p.rateHistory.length > 1
|
|
749
|
+
? `<div class="rate-hist">${p.rateHistory.map((r) =>
|
|
750
|
+
`<span>${esc(fmtMoney(r.minor, r.currency))}/h desde ${esc(r.from)}</span>`).join("")}</div>`
|
|
751
|
+
: "";
|
|
752
|
+
|
|
753
|
+
parts.push(`
|
|
754
|
+
<div class="pcard" data-project="${esc(p.id)}">
|
|
755
|
+
<div class="pcard-head">
|
|
756
|
+
<div>
|
|
757
|
+
<div class="pcard-name">${esc(p.name)}</div>
|
|
758
|
+
<div class="pcard-meta">${esc(p.clientName)} · ${fmtDuration(p.seconds)} en ${p.blocks} bloques</div>
|
|
759
|
+
</div>
|
|
760
|
+
<div class="pcard-rate">
|
|
761
|
+
<div class="pcard-rate-v">${rate ? esc(rate) + "/h" : "sin tarifa"}</div>
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
${p.repoPaths.length
|
|
765
|
+
? `<div class="repos">${p.repoPaths.map((r) => `<code>${esc(r)}</code>`).join("")}</div>`
|
|
766
|
+
: `<p class="pcard-warn">Sin repositorio asignado: no se le imputará nada automáticamente.</p>`}
|
|
767
|
+
${history}
|
|
768
|
+
<div class="share-block">
|
|
769
|
+
<button type="button" class="chip share-btn${pubs.get(p.id) && pubs.get(p.id).staleBlocks > 0 ? " is-stale" : ""}">
|
|
770
|
+
${pubs.has(p.id) ? "Actualizar lo compartido" : "Compartir con el cliente"}
|
|
771
|
+
</button>
|
|
772
|
+
<span class="share-hint">${
|
|
773
|
+
!pubs.has(p.id) ? "Genera un panel de solo lectura con enlace no adivinable."
|
|
774
|
+
: pubs.get(p.id).staleBlocks > 0
|
|
775
|
+
? `<b class="stale-mark">${pubs.get(p.id).staleBlocks} bloques nuevos</b> desde que lo publicaste.`
|
|
776
|
+
: `Publicado ${
|
|
777
|
+
pubs.get(p.id).daysAgo === 0 ? "hoy" :
|
|
778
|
+
pubs.get(p.id).daysAgo === 1 ? "ayer" :
|
|
779
|
+
"hace " + pubs.get(p.id).daysAgo + " días"} · al día.`
|
|
780
|
+
}</span>
|
|
781
|
+
<div class="share-out" hidden></div>
|
|
782
|
+
</div>
|
|
783
|
+
<form class="rateform">
|
|
784
|
+
<span>Cambiar tarifa a</span>
|
|
785
|
+
<input type="number" class="rate-new" min="0" step="0.5"
|
|
786
|
+
value="${p.hourlyMinor ? (p.hourlyMinor / 100) : ""}" placeholder="13">
|
|
787
|
+
<span>${esc(p.currency)}/h desde</span>
|
|
788
|
+
<input type="date" class="rate-from" value="${localToday()}">
|
|
789
|
+
<button type="submit" class="chip">Guardar</button>
|
|
790
|
+
</form>
|
|
791
|
+
</div>`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (!data.projects.length) {
|
|
795
|
+
parts.push(`<div class="empty"><h2>Todavía no hay proyectos</h2>
|
|
796
|
+
<p>Crea el primero abajo y asígnale el repositorio donde trabajas.</p></div>`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Repos con trabajo capturado que no pertenecen a nadie. Es la lista más
|
|
800
|
+
// útil de la pantalla: son horas que ya tienes pero no puedes facturar.
|
|
801
|
+
// Se presentan como tarjetas y no como un desplegable porque rellenar un
|
|
802
|
+
// formulario por repositorio, con cuarenta pendientes, no lo hace nadie.
|
|
803
|
+
const orphanCards = orphans.slice(0, 12).map((r) => `
|
|
804
|
+
<button type="button" class="orphan" data-path="${esc(r.path)}" data-name="${esc(r.name)}">
|
|
805
|
+
<span class="orphan-name">${esc(r.name)}</span>
|
|
806
|
+
<span class="orphan-meta">${r.turns} turnos · hasta ${esc(r.lastAt)}</span>
|
|
807
|
+
</button>`).join("");
|
|
808
|
+
|
|
809
|
+
const orphanOptions = orphans.slice(0, 40)
|
|
810
|
+
.map((r) => `<option value="${esc(r.path)}">${esc(r.name)} — ${r.turns} turnos, hasta ${esc(r.lastAt)}</option>`)
|
|
811
|
+
.join("");
|
|
812
|
+
|
|
813
|
+
const clientOptions = data.clients
|
|
814
|
+
.map((c) => `<option value="${esc(c.id)}">${esc(c.name)} (${esc(c.currency)})</option>`).join("");
|
|
815
|
+
|
|
816
|
+
parts.push(`
|
|
817
|
+
<h2 class="block-title">Nuevo proyecto</h2>
|
|
818
|
+
<form class="newproject" id="new-project">
|
|
819
|
+
${orphans.length ? `<p class="addform-hint">Tienes ${orphans.length} repositorios con trabajo
|
|
820
|
+
capturado y sin proyecto. Son horas que ya registraste pero todavía no puedes informar.
|
|
821
|
+
Pulsa uno para empezar:</p>
|
|
822
|
+
<div class="orphans">${orphanCards}</div>` : ""}
|
|
823
|
+
<label class="field">
|
|
824
|
+
<span>Repositorio</span>
|
|
825
|
+
<select id="np-repo">
|
|
826
|
+
<option value="">— elegir uno de los detectados —</option>
|
|
827
|
+
${orphanOptions}
|
|
828
|
+
</select>
|
|
829
|
+
</label>
|
|
830
|
+
<div class="addrow">
|
|
831
|
+
<label class="field"><span>Nombre del proyecto</span>
|
|
832
|
+
<input id="np-name" type="text" placeholder="App de Acme" required></label>
|
|
833
|
+
<label class="field"><span>Tarifa por hora</span>
|
|
834
|
+
<input id="np-rate" type="number" min="0" step="0.5" placeholder="45"></label>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="addrow">
|
|
837
|
+
<label class="field"><span>Cliente existente</span>
|
|
838
|
+
<select id="np-client"><option value="">— cliente nuevo —</option>${clientOptions}</select></label>
|
|
839
|
+
<label class="field"><span>…o cliente nuevo</span>
|
|
840
|
+
<input id="np-clientname" type="text" placeholder="Acme S.L."></label>
|
|
841
|
+
<label class="field np-cur"><span>Moneda</span>
|
|
842
|
+
<select id="np-currency">
|
|
843
|
+
<option>EUR</option><option>USD</option><option>GBP</option>
|
|
844
|
+
<option>MXN</option><option>COP</option><option>BRL</option>
|
|
845
|
+
</select></label>
|
|
846
|
+
</div>
|
|
847
|
+
<div class="addactions"><button type="submit" class="btn-main">Crear proyecto</button></div>
|
|
848
|
+
</form>
|
|
849
|
+
<p class="note">Tras crear el proyecto, ejecuta <code>estela import</code> para que el
|
|
850
|
+
trabajo ya capturado de ese repositorio se impute.</p>`);
|
|
851
|
+
|
|
852
|
+
$("#projects-body").innerHTML = parts.join("");
|
|
853
|
+
wireProjects();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Pregunta con qué correo commiteas, listando los autores reales del repo.
|
|
858
|
+
* Adivinarlo por la configuración de git captura los commits de otro, o ninguno.
|
|
859
|
+
*/
|
|
860
|
+
async function askAuthor(projectId, repoPath) {
|
|
861
|
+
const { authors } = await api(`/api/authors?repo=${encodeURIComponent(repoPath)}`);
|
|
862
|
+
if (!authors.length) return;
|
|
863
|
+
|
|
864
|
+
const card = document.querySelector(`.pcard[data-project="${CSS.escape(projectId)}"]`);
|
|
865
|
+
if (!card) return;
|
|
866
|
+
|
|
867
|
+
const box = document.createElement("div");
|
|
868
|
+
box.className = "authorbox";
|
|
869
|
+
box.innerHTML =
|
|
870
|
+
`<p class="addform-hint">¿Con cuál de estos correos commiteas tú en este repositorio?
|
|
871
|
+
Si eliges mal, se capturarán los commits de otra persona.</p>` +
|
|
872
|
+
authors.slice(0, 8).map((a) =>
|
|
873
|
+
`<button type="button" class="chip author-pick" data-email="${esc(a.email)}">
|
|
874
|
+
${esc(a.email)} <span class="author-n">${a.commits}</span>
|
|
875
|
+
</button>`).join("");
|
|
876
|
+
card.appendChild(box);
|
|
877
|
+
box.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
878
|
+
|
|
879
|
+
box.querySelectorAll(".author-pick").forEach((btn) => {
|
|
880
|
+
btn.addEventListener("click", async () => {
|
|
881
|
+
await api("/api/authors", {
|
|
882
|
+
method: "POST",
|
|
883
|
+
body: JSON.stringify({ projectId, emails: btn.dataset.email }),
|
|
884
|
+
});
|
|
885
|
+
await loadProjects();
|
|
886
|
+
toast("Identidad guardada — ejecuta estela import");
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function wireProjects() {
|
|
892
|
+
// El nombre del proyecto se rellena solo con el del repo elegido: teclear dos
|
|
893
|
+
// veces lo mismo es la clase de fricción que hace que nadie configure nada.
|
|
894
|
+
const repo = $("#np-repo");
|
|
895
|
+
repo?.addEventListener("change", () => {
|
|
896
|
+
const name = $("#np-name");
|
|
897
|
+
if (repo.value && !name.value) {
|
|
898
|
+
name.value = repo.selectedOptions[0].textContent.split(" — ")[0].trim();
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
// Un clic en la tarjeta rellena el formulario y baja hasta él.
|
|
903
|
+
document.querySelectorAll(".orphan").forEach((card) => {
|
|
904
|
+
card.addEventListener("click", () => {
|
|
905
|
+
$("#np-repo").value = card.dataset.path;
|
|
906
|
+
$("#np-name").value = card.dataset.name.replace(/[-_]+/g, " ");
|
|
907
|
+
document.querySelectorAll(".orphan").forEach((c) => c.classList.remove("is-on"));
|
|
908
|
+
card.classList.add("is-on");
|
|
909
|
+
$("#np-name").focus();
|
|
910
|
+
$("#new-project").scrollIntoView({ behavior: "smooth", block: "center" });
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
$("#new-project")?.addEventListener("submit", async (e) => {
|
|
915
|
+
e.preventDefault();
|
|
916
|
+
const rate = Number($("#np-rate").value);
|
|
917
|
+
try {
|
|
918
|
+
const nuevo = await api("/api/projects", {
|
|
919
|
+
method: "POST",
|
|
920
|
+
body: JSON.stringify({
|
|
921
|
+
name: $("#np-name").value,
|
|
922
|
+
repoPath: $("#np-repo").value,
|
|
923
|
+
clientId: $("#np-client").value,
|
|
924
|
+
clientName: $("#np-clientname").value,
|
|
925
|
+
currency: $("#np-currency").value,
|
|
926
|
+
hourlyMinor: rate > 0 ? Math.round(rate * 100) : undefined,
|
|
927
|
+
}),
|
|
928
|
+
});
|
|
929
|
+
await loadProjects();
|
|
930
|
+
toast("Proyecto creado");
|
|
931
|
+
// El correo de commit es lo primero que hay que configurar: sin él se
|
|
932
|
+
// capturan los commits equivocados, o casi ninguno.
|
|
933
|
+
if ($("#np-repo").value) await askAuthor(nuevo.id, $("#np-repo").value);
|
|
934
|
+
} catch (error) { toast(error.message); }
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
document.querySelectorAll(".share-btn").forEach((btn) => {
|
|
938
|
+
btn.addEventListener("click", async () => {
|
|
939
|
+
const card = btn.closest(".pcard");
|
|
940
|
+
const out = card.querySelector(".share-out");
|
|
941
|
+
btn.disabled = true;
|
|
942
|
+
try {
|
|
943
|
+
const r = await api("/api/publish", {
|
|
944
|
+
method: "POST",
|
|
945
|
+
body: JSON.stringify({
|
|
946
|
+
projectId: card.dataset.project,
|
|
947
|
+
baseUrl: localStorage.getItem("estela.baseUrl") || "",
|
|
948
|
+
token: localStorage.getItem("estela.token." + card.dataset.project) || "",
|
|
949
|
+
}),
|
|
950
|
+
});
|
|
951
|
+
// Guardar el token: republicar con otro le rompería el enlace al cliente.
|
|
952
|
+
localStorage.setItem("estela.token." + card.dataset.project, r.token);
|
|
953
|
+
|
|
954
|
+
out.hidden = false;
|
|
955
|
+
out.innerHTML =
|
|
956
|
+
'<p class="share-line"><b>Panel generado</b> — ' + r.sizeKb + ' KB, un solo fichero.</p>' +
|
|
957
|
+
(r.url
|
|
958
|
+
? '<p class="share-line">Enlace: <code>' + esc(r.url) + '</code></p>'
|
|
959
|
+
: '<label class="field"><span>Tu dominio (se recuerda)</span>' +
|
|
960
|
+
'<input class="base-url" type="text" placeholder="https://estela.tudominio.com"></label>') +
|
|
961
|
+
'<p class="share-line">1. Súbelo:</p><code class="cmd">' + esc(r.uploadCommand) + '</code>' +
|
|
962
|
+
'<p class="share-warn">Quien tenga el enlace, entra: no hay contraseña. Para revocarlo, ' +
|
|
963
|
+
'borra <code>e/' + esc(r.token) + '/</code> del servidor.</p>';
|
|
964
|
+
|
|
965
|
+
out.querySelector(".base-url")?.addEventListener("change", (e) => {
|
|
966
|
+
localStorage.setItem("estela.baseUrl", e.target.value.trim());
|
|
967
|
+
btn.click();
|
|
968
|
+
});
|
|
969
|
+
toast("Panel generado en ~/.estela/publicar");
|
|
970
|
+
} catch (error) { toast(error.message); }
|
|
971
|
+
finally { btn.disabled = false; }
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
document.querySelectorAll(".rateform").forEach((form) => {
|
|
976
|
+
form.addEventListener("submit", async (e) => {
|
|
977
|
+
e.preventDefault();
|
|
978
|
+
const projectId = form.closest(".pcard").dataset.project;
|
|
979
|
+
const value = Number(form.querySelector(".rate-new").value);
|
|
980
|
+
if (!(value > 0)) { toast("Escribe una tarifa"); return; }
|
|
981
|
+
try {
|
|
982
|
+
await api("/api/rate", {
|
|
983
|
+
method: "POST",
|
|
984
|
+
body: JSON.stringify({
|
|
985
|
+
projectId,
|
|
986
|
+
hourlyMinor: Math.round(value * 100),
|
|
987
|
+
from: form.querySelector(".rate-from").value,
|
|
988
|
+
}),
|
|
989
|
+
});
|
|
990
|
+
await loadProjects();
|
|
991
|
+
toast("Tarifa guardada — lo ya trabajado conserva la anterior");
|
|
992
|
+
} catch (error) { toast(error.message); }
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ── Informes ───────────────────────────────────────────────────────────
|
|
998
|
+
|
|
999
|
+
async function loadReports() {
|
|
1000
|
+
// Mismo periodo que Resumen: si las dos pantallas contestan a la misma
|
|
1001
|
+
// pregunta, tienen que dar el mismo número.
|
|
1002
|
+
const { from, to } = periodRange(state.period);
|
|
1003
|
+
const overview = await api(`/api/overview?from=${from}&to=${to}`);
|
|
1004
|
+
const parts = [];
|
|
1005
|
+
|
|
1006
|
+
parts.push(`<div class="filters" id="rep-periods">
|
|
1007
|
+
<button type="button" class="period${state.period === "week" ? " is-on" : ""}" data-period="week">Esta semana</button>
|
|
1008
|
+
<button type="button" class="period${state.period === "month" ? " is-on" : ""}" data-period="month">Este mes</button>
|
|
1009
|
+
<button type="button" class="period${state.period === "quarter" ? " is-on" : ""}" data-period="quarter">3 meses</button>
|
|
1010
|
+
<button type="button" class="period${state.period === "all" ? " is-on" : ""}" data-period="all">Todo lo pendiente</button>
|
|
1011
|
+
</div>`);
|
|
1012
|
+
|
|
1013
|
+
const fuera = overview.outsideRange;
|
|
1014
|
+
if (fuera && fuera.blocks > 0) {
|
|
1015
|
+
parts.push(`<p class="note">Hay además <b>${fmtDuration(fuera.seconds)}</b> pendientes
|
|
1016
|
+
fuera de este periodo, en ${fuera.blocks} bloques. Pulsa
|
|
1017
|
+
<b>Todo lo pendiente</b> para incluirlos.</p>`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (overview.warning) parts.push(`<div class="warn">${esc(overview.warning)}</div>`);
|
|
1021
|
+
|
|
1022
|
+
if (overview.unbilled.length === 0) {
|
|
1023
|
+
parts.push(`<div class="empty">
|
|
1024
|
+
<h2>Nada que informar todavía</h2>
|
|
1025
|
+
<p>Aquí aparecerán los proyectos con horas aprobadas y sin informar.</p>
|
|
1026
|
+
<code>estela status</code>
|
|
1027
|
+
</div>`);
|
|
1028
|
+
} else {
|
|
1029
|
+
for (const row of overview.unbilled) {
|
|
1030
|
+
const amount = fmtMoney(row.amountMinor, row.currency);
|
|
1031
|
+
parts.push(`
|
|
1032
|
+
<div class="report">
|
|
1033
|
+
<div class="report-text">
|
|
1034
|
+
<div class="report-name">${esc(row.projectName)}</div>
|
|
1035
|
+
<div class="report-client">${esc(row.clientName)} · ${row.blocks} bloques ·
|
|
1036
|
+
todo lo pendiente desde ${esc(row.firstDay)}</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
<div class="report-figs">
|
|
1039
|
+
<div class="report-hours">${fmtDuration(row.seconds)}</div>
|
|
1040
|
+
<div class="report-amount">${amount || "—"}</div>
|
|
1041
|
+
</div>
|
|
1042
|
+
<form class="report-actions" data-project="${esc(row.projectId)}">
|
|
1043
|
+
<label class="field"><span>Desde</span>
|
|
1044
|
+
<input type="date" class="rep-from" value="${esc(row.firstDay)}"></label>
|
|
1045
|
+
<label class="field"><span>Hasta</span>
|
|
1046
|
+
<input type="date" class="rep-to" value="${localToday()}"></label>
|
|
1047
|
+
<label class="field"><span>Tu nombre</span>
|
|
1048
|
+
<input type="text" class="rep-author" placeholder="Jonathan León"></label>
|
|
1049
|
+
<label class="checkline"><input type="checkbox" class="rep-amounts">
|
|
1050
|
+
<span>Incluir importes</span></label>
|
|
1051
|
+
<button type="submit" class="btn-main">Descargar informe</button>
|
|
1052
|
+
</form>
|
|
1053
|
+
</div>`);
|
|
1054
|
+
}
|
|
1055
|
+
parts.push(`<p class="note">El informe recoge horas y commits. No es una factura:
|
|
1056
|
+
Estela no emite documentos fiscales, adjunta este respaldo a la tuya.</p>`);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
$("#reports-body").innerHTML = parts.join("");
|
|
1060
|
+
|
|
1061
|
+
document.querySelectorAll("#rep-periods .period").forEach((btn) => {
|
|
1062
|
+
btn.addEventListener("click", () => { state.period = btn.dataset.period; loadReports(); });
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
document.querySelectorAll(".report-actions").forEach((form) => {
|
|
1066
|
+
form.addEventListener("submit", (e) => {
|
|
1067
|
+
e.preventDefault();
|
|
1068
|
+
const q = new URLSearchParams({
|
|
1069
|
+
project: form.dataset.project,
|
|
1070
|
+
from: form.querySelector(".rep-from").value,
|
|
1071
|
+
to: form.querySelector(".rep-to").value,
|
|
1072
|
+
author: form.querySelector(".rep-author").value,
|
|
1073
|
+
amounts: form.querySelector(".rep-amounts").checked ? "1" : "0",
|
|
1074
|
+
});
|
|
1075
|
+
// El servidor lo devuelve con Content-Disposition, así que el navegador
|
|
1076
|
+
// lo descarga en vez de abrirlo.
|
|
1077
|
+
window.location.href = `/api/report?${q}`;
|
|
1078
|
+
toast("Informe descargado");
|
|
1079
|
+
});
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ── Navegación ─────────────────────────────────────────────────────────
|
|
1084
|
+
|
|
1085
|
+
function moveDay(days) {
|
|
1086
|
+
state.date = shiftDay(state.date, days);
|
|
1087
|
+
state.open.clear();
|
|
1088
|
+
loadDay();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function showView(name) {
|
|
1092
|
+
state.view = name;
|
|
1093
|
+
document.querySelector("main").classList.toggle("wide", name === "team");
|
|
1094
|
+
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("is-on", t.dataset.view === name));
|
|
1095
|
+
document.querySelectorAll(".view").forEach((v) => v.classList.toggle("is-on", v.id === `view-${name}`));
|
|
1096
|
+
if (name === "summary") loadSummary();
|
|
1097
|
+
if (name === "day") loadDay();
|
|
1098
|
+
if (name === "team") loadTeam();
|
|
1099
|
+
if (name === "reports") loadReports();
|
|
1100
|
+
if (name === "projects") loadProjects();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
document.querySelectorAll(".period").forEach((btn) => {
|
|
1104
|
+
btn.addEventListener("click", () => {
|
|
1105
|
+
state.period = btn.dataset.period;
|
|
1106
|
+
document.querySelectorAll(".period").forEach((b) => b.classList.toggle("is-on", b === btn));
|
|
1107
|
+
loadSummary();
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
$("#prev-day").addEventListener("click", () => moveDay(-1));
|
|
1112
|
+
$("#next-day").addEventListener("click", () => moveDay(1));
|
|
1113
|
+
$("#go-today").addEventListener("click", () => {
|
|
1114
|
+
state.date = localToday();
|
|
1115
|
+
state.open.clear();
|
|
1116
|
+
loadDay();
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
/* "Queda 1 bloque por revisar" sin decir cuál obliga a buscarlo a ojo. El
|
|
1120
|
+
aviso es el propio botón que te lleva hasta él y lo resalta. */
|
|
1121
|
+
$("#approve-text").addEventListener("click", () => {
|
|
1122
|
+
const first = state.day?.entries.find((e) => !e.approved && !e.invoiced);
|
|
1123
|
+
if (!first) return;
|
|
1124
|
+
|
|
1125
|
+
if (!state.open.has(first.id)) {
|
|
1126
|
+
state.open.add(first.id);
|
|
1127
|
+
renderDay();
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const row = document.querySelector(`.row[data-id="${CSS.escape(first.id)}"]`);
|
|
1131
|
+
if (!row) return;
|
|
1132
|
+
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
1133
|
+
row.classList.add("is-target");
|
|
1134
|
+
setTimeout(() => row.classList.remove("is-target"), 1600);
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
$("#approve-btn").addEventListener("click", async () => {
|
|
1138
|
+
try {
|
|
1139
|
+
const { approved } = await api("/api/approve-day", {
|
|
1140
|
+
method: "POST",
|
|
1141
|
+
body: JSON.stringify({ date: state.date }),
|
|
1142
|
+
});
|
|
1143
|
+
await loadDay();
|
|
1144
|
+
toast(approved === 1 ? "1 bloque aprobado" : `${approved} bloques aprobados`);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
toast(error.message);
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
/* ── Mantenerse al día ──────────────────────────────────────────────────
|
|
1151
|
+
|
|
1152
|
+
El servidor reimporta cada cinco minutos, pero la pestaña abierta no se
|
|
1153
|
+
entera: se cargaba una vez y ya. Commiteabas, mirabas Mi día y no estaba,
|
|
1154
|
+
sin saber si el problema era el sistema o tu commit.
|
|
1155
|
+
|
|
1156
|
+
Tres piezas, por orden de importancia:
|
|
1157
|
+
|
|
1158
|
+
- Volver a la pestaña actualiza. Es el momento natural: vienes del editor
|
|
1159
|
+
de hacer el commit. No hay que acordarse de nada.
|
|
1160
|
+
- El botón fuerza un import. Acabas de commitear y lo quieres AHORA, no
|
|
1161
|
+
dentro de cinco minutos.
|
|
1162
|
+
- Si el import falla, se dice. Estuvo nueve horas fallando en silencio y
|
|
1163
|
+
la web enseñaba datos viejos como si fueran buenos: en una herramienta
|
|
1164
|
+
de tiempo, ese es el peor fallo posible. */
|
|
1165
|
+
|
|
1166
|
+
function reloadView() {
|
|
1167
|
+
if (state.view === "summary") return loadSummary();
|
|
1168
|
+
if (state.view === "day") return loadDay();
|
|
1169
|
+
if (state.view === "team") return loadTeam();
|
|
1170
|
+
if (state.view === "reports") return loadReports();
|
|
1171
|
+
if (state.view === "projects") return loadProjects();
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function showSync(status) {
|
|
1175
|
+
const warn = $("#sync-warn");
|
|
1176
|
+
if (status && status.ok === false) {
|
|
1177
|
+
warn.textContent = "No se pudo actualizar";
|
|
1178
|
+
warn.title = status.error || "";
|
|
1179
|
+
warn.hidden = false;
|
|
1180
|
+
} else {
|
|
1181
|
+
warn.hidden = true;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
let syncing = false;
|
|
1186
|
+
|
|
1187
|
+
async function sync({ force }) {
|
|
1188
|
+
if (syncing) return;
|
|
1189
|
+
syncing = true;
|
|
1190
|
+
const btn = $("#sync");
|
|
1191
|
+
const label = $("#sync-label");
|
|
1192
|
+
btn.classList.add("is-busy");
|
|
1193
|
+
label.textContent = "Buscando…";
|
|
1194
|
+
try {
|
|
1195
|
+
// force = el botón: pide un import nuevo. Sin force solo se consulta cómo
|
|
1196
|
+
// fue el último, que es lo que basta al volver a la pestaña.
|
|
1197
|
+
const status = force
|
|
1198
|
+
? await api("/api/import", { method: "POST" })
|
|
1199
|
+
: await api("/api/import-status");
|
|
1200
|
+
showSync(status);
|
|
1201
|
+
await reloadView();
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
showSync({ ok: false, error: String(error) });
|
|
1204
|
+
} finally {
|
|
1205
|
+
btn.classList.remove("is-busy");
|
|
1206
|
+
label.textContent = "Actualizar";
|
|
1207
|
+
syncing = false;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
$("#sync").addEventListener("click", () => sync({ force: true }));
|
|
1212
|
+
|
|
1213
|
+
/* Al volver a la pestaña. Con margen: alternar entre ventanas mientras
|
|
1214
|
+
trabajas no debe disparar un import en cada cambio. */
|
|
1215
|
+
let leftAt = 0;
|
|
1216
|
+
document.addEventListener("visibilitychange", () => {
|
|
1217
|
+
if (document.hidden) { leftAt = Date.now(); return; }
|
|
1218
|
+
if (Date.now() - leftAt > 30_000) sync({ force: true });
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
document.addEventListener("keydown", (e) => {
|
|
1222
|
+
if (e.key.toLowerCase() === "r" && !e.metaKey && !e.ctrlKey && !e.altKey &&
|
|
1223
|
+
!/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "")) {
|
|
1224
|
+
e.preventDefault();
|
|
1225
|
+
sync({ force: true });
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
// Al abrir: saber si el último import fue bien, sin forzar uno.
|
|
1230
|
+
api("/api/import-status").then(showSync).catch(() => {});
|
|
1231
|
+
|
|
1232
|
+
document.querySelectorAll(".tab").forEach((tab) => {
|
|
1233
|
+
tab.addEventListener("click", () => showView(tab.dataset.view));
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
document.addEventListener("keydown", (e) => {
|
|
1237
|
+
if (e.target.matches("input, select, textarea")) return;
|
|
1238
|
+
if (e.key === "ArrowLeft") moveDay(-1);
|
|
1239
|
+
if (e.key === "ArrowRight" && !$("#next-day").disabled) moveDay(1);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
// ── Arranque ───────────────────────────────────────────────────────────
|
|
1243
|
+
|
|
1244
|
+
/* Arranca en el resumen: la primera pregunta al abrir es "¿cómo va todo?",
|
|
1245
|
+
no "¿qué hice hoy?". El día concreto está a un clic. */
|
|
1246
|
+
(async function boot() {
|
|
1247
|
+
await loadSummary();
|
|
1248
|
+
|
|
1249
|
+
/* Si hoy no tiene actividad, "Mi día" apunta al último día con trabajo:
|
|
1250
|
+
abrir una pantalla en blanco no dice nada. */
|
|
1251
|
+
const overview = await api("/api/overview");
|
|
1252
|
+
if (overview.days.length > 0) {
|
|
1253
|
+
const today = localToday();
|
|
1254
|
+
const hasToday = overview.days.some((d) => d.date === today);
|
|
1255
|
+
if (!hasToday) state.date = overview.days[0].date;
|
|
1256
|
+
}
|
|
1257
|
+
})();
|