estela 0.1.19 → 0.1.21
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/LICENSE +1 -1
- package/dist/billing/amortize.d.ts.map +1 -1
- package/dist/billing/amortize.js +3 -2
- package/dist/billing/amortize.js.map +1 -1
- package/dist/billing/invoice.d.ts.map +1 -1
- package/dist/billing/invoice.js +19 -18
- package/dist/billing/invoice.js.map +1 -1
- package/dist/billing/sessionize.d.ts.map +1 -1
- package/dist/billing/sessionize.js +3 -2
- package/dist/billing/sessionize.js.map +1 -1
- package/dist/billing.d.ts.map +1 -1
- package/dist/billing.js +5 -4
- package/dist/billing.js.map +1 -1
- package/dist/bin.js +6 -2
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +311 -164
- package/dist/cli.js.map +1 -1
- package/dist/cloud/auth.d.ts.map +1 -1
- package/dist/cloud/auth.js +9 -8
- package/dist/cloud/auth.js.map +1 -1
- package/dist/cloud/client.d.ts.map +1 -1
- package/dist/cloud/client.js +11 -6
- package/dist/cloud/client.js.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +4 -3
- package/dist/db/schema.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +50 -45
- package/dist/doctor.js.map +1 -1
- package/dist/i18n/en.d.ts +10 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +326 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/i18n.test.d.ts +2 -0
- package/dist/i18n/i18n.test.d.ts.map +1 -0
- package/dist/i18n/i18n.test.js +201 -0
- package/dist/i18n/i18n.test.js.map +1 -0
- package/dist/i18n/index.d.ts +54 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +106 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +6 -5
- package/dist/publish.js.map +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +50 -25
- package/dist/server.js.map +1 -1
- package/dist/server.test.d.ts +2 -0
- package/dist/server.test.d.ts.map +1 -0
- package/dist/server.test.js +125 -0
- package/dist/server.test.js.map +1 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +8 -5
- package/dist/setup.js.map +1 -1
- package/dist/sync/team.d.ts.map +1 -1
- package/dist/sync/team.js +7 -6
- package/dist/sync/team.js.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +7 -6
- package/dist/sync.js.map +1 -1
- package/dist/team.d.ts.map +1 -1
- package/dist/team.js +9 -8
- package/dist/team.js.map +1 -1
- package/dist/updateCheck.d.ts.map +1 -1
- package/dist/updateCheck.js +3 -2
- package/dist/updateCheck.js.map +1 -1
- package/dist/watchers/claude.d.ts.map +1 -1
- package/dist/watchers/claude.js +3 -2
- package/dist/watchers/claude.js.map +1 -1
- package/dist/web-ui.test.js +96 -7
- package/dist/web-ui.test.js.map +1 -1
- package/node_modules/@estela/shared/dist/money.d.ts +1 -0
- package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/money.js +9 -1
- package/node_modules/@estela/shared/dist/money.js.map +1 -1
- package/package.json +1 -1
- package/web/app.js +213 -223
- package/web/i18n.js +635 -0
- package/web/index.html +40 -34
- package/web/lib.js +33 -8
package/web/app.js
CHANGED
|
@@ -18,21 +18,20 @@ const state = {
|
|
|
18
18
|
// ── Formato ────────────────────────────────────────────────────────────
|
|
19
19
|
// Las funciones puras viven en lib.js para poder probarlas sin navegador.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
["other", "Otro"],
|
|
25
|
-
];
|
|
21
|
+
// Los textos salen de i18n.js, que ya decidió el idioma antes de cargar esto.
|
|
22
|
+
const KINDS = ["development", "meeting", "research", "review", "travel", "support", "other"]
|
|
23
|
+
.map((kind) => [kind, tr(`kind.${kind}`)]);
|
|
26
24
|
|
|
27
25
|
function fmtTime(iso) {
|
|
28
|
-
return new Date(iso).toLocaleTimeString(
|
|
26
|
+
return new Date(iso).toLocaleTimeString(localeTag(), { hour: "2-digit", minute: "2-digit" });
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
// ── Red ────────────────────────────────────────────────────────────────
|
|
32
30
|
|
|
33
31
|
async function api(path, options) {
|
|
34
32
|
const res = await fetch(path, {
|
|
35
|
-
|
|
33
|
+
// El servidor local traduce sus pocos mensajes de error con esto.
|
|
34
|
+
headers: { "Content-Type": "application/json", "X-Estela-Lang": getLang() },
|
|
36
35
|
...options,
|
|
37
36
|
});
|
|
38
37
|
const body = await res.json();
|
|
@@ -78,7 +77,7 @@ function renderDay() {
|
|
|
78
77
|
if (hasEntries) {
|
|
79
78
|
$("#day-hours").textContent = fmtDuration(day.totalSeconds);
|
|
80
79
|
$("#day-blocks").textContent =
|
|
81
|
-
day.
|
|
80
|
+
tr("day.inBlocks", { n: day.entries.length });
|
|
82
81
|
|
|
83
82
|
// Con varias monedas se enseñan todas. Decir "sin tarifa" cuando las hay
|
|
84
83
|
// manda a revisar una configuración que está bien.
|
|
@@ -89,10 +88,10 @@ function renderDay() {
|
|
|
89
88
|
|
|
90
89
|
$("#day-amount").textContent = money || "—";
|
|
91
90
|
$("#day-amount").parentElement.querySelector(".hero-label").textContent =
|
|
92
|
-
totals.length > 1 ? "
|
|
93
|
-
: totals.length === 1 ? "
|
|
94
|
-
: day.missingRate ? "
|
|
95
|
-
: "
|
|
91
|
+
totals.length > 1 ? tr("day.toInvoiceByCurrency")
|
|
92
|
+
: totals.length === 1 ? tr("day.toInvoice")
|
|
93
|
+
: day.missingRate ? tr("day.noRate")
|
|
94
|
+
: tr("day.noBillable");
|
|
96
95
|
} else {
|
|
97
96
|
// Vaciar aunque esté oculto: una cifra vieja esperando en el DOM es un
|
|
98
97
|
// número equivocado a la espera de que algo la muestre por accidente.
|
|
@@ -108,9 +107,7 @@ function renderDay() {
|
|
|
108
107
|
const pending = day.pendingApproval;
|
|
109
108
|
$("#approve-bar").hidden = !hasEntries || pending === 0;
|
|
110
109
|
if (pending > 0) {
|
|
111
|
-
$("#approve-text").textContent = pending
|
|
112
|
-
? "Queda 1 bloque por revisar — llévame a él"
|
|
113
|
-
: `Quedan ${pending} bloques por revisar — llévame al primero`;
|
|
110
|
+
$("#approve-text").textContent = tr("day.pending", { n: pending });
|
|
114
111
|
}
|
|
115
112
|
}
|
|
116
113
|
|
|
@@ -118,15 +115,14 @@ function renderEmpty() {
|
|
|
118
115
|
const today = localToday();
|
|
119
116
|
if (state.date === today) {
|
|
120
117
|
return `<div class="empty">
|
|
121
|
-
<h2
|
|
122
|
-
<p>
|
|
123
|
-
Si ya trabajaste, trae lo de hoy:</p>
|
|
118
|
+
<h2>${tr("day.emptyToday.title")}</h2>
|
|
119
|
+
<p>${tr("day.emptyToday.body")}</p>
|
|
124
120
|
<code>estela import</code>
|
|
125
121
|
</div>`;
|
|
126
122
|
}
|
|
127
123
|
return `<div class="empty">
|
|
128
|
-
<h2
|
|
129
|
-
<p
|
|
124
|
+
<h2>${tr("day.empty.title")}</h2>
|
|
125
|
+
<p>${tr("day.empty.body")}</p>
|
|
130
126
|
</div>`;
|
|
131
127
|
}
|
|
132
128
|
|
|
@@ -151,9 +147,9 @@ function renderRow(entry) {
|
|
|
151
147
|
const meta = [
|
|
152
148
|
entry.projectName,
|
|
153
149
|
entry.clientName,
|
|
154
|
-
entry.source === "manual" ? "
|
|
150
|
+
entry.source === "manual" ? tr("row.manual")
|
|
155
151
|
: entry.source === "commit"
|
|
156
|
-
? `${fmtTime(entry.startedAt)}–${fmtTime(entry.endedAt)} ·
|
|
152
|
+
? `${fmtTime(entry.startedAt)}–${fmtTime(entry.endedAt)} · ${tr("row.fromCommits")}`
|
|
157
153
|
: `${fmtTime(entry.startedAt)}–${fmtTime(entry.endedAt)}`,
|
|
158
154
|
].filter(Boolean).join(" · ");
|
|
159
155
|
|
|
@@ -167,7 +163,7 @@ function renderRow(entry) {
|
|
|
167
163
|
</span>
|
|
168
164
|
<span class="row-figs">
|
|
169
165
|
<span class="row-time">${fmtDuration(entry.seconds)}</span>
|
|
170
|
-
<span class="row-money ${money ? "" : "none"}">${money || (entry.billable ? "
|
|
166
|
+
<span class="row-money ${money ? "" : "none"}">${money || (entry.billable ? tr("row.noRate") : tr("row.notBillable"))}</span>
|
|
171
167
|
</span>
|
|
172
168
|
<span class="chev" aria-hidden="true">›</span>
|
|
173
169
|
</button>
|
|
@@ -186,12 +182,12 @@ function renderRowBody(entry) {
|
|
|
186
182
|
: "";
|
|
187
183
|
|
|
188
184
|
const ai = entry.aiMicroUsd > 0
|
|
189
|
-
? `<p class="detail-line"
|
|
185
|
+
? `<p class="detail-line">${tr("row.ai", { amount: "$" + (entry.aiMicroUsd / 1e6).toFixed(2) })}</p>`
|
|
190
186
|
: "";
|
|
191
187
|
|
|
192
188
|
if (entry.invoiced) {
|
|
193
189
|
return `<div class="row-body">
|
|
194
|
-
<p class="detail-line"
|
|
190
|
+
<p class="detail-line">${tr("row.invoiced")}</p>
|
|
195
191
|
${commits}${ai}
|
|
196
192
|
</div>`;
|
|
197
193
|
}
|
|
@@ -199,16 +195,16 @@ function renderRowBody(entry) {
|
|
|
199
195
|
return `
|
|
200
196
|
<div class="row-body">
|
|
201
197
|
<div class="field">
|
|
202
|
-
<label for="d-${esc(entry.id)}"
|
|
198
|
+
<label for="d-${esc(entry.id)}">${tr("field.what")}</label>
|
|
203
199
|
<input id="d-${esc(entry.id)}" type="text" value="${esc(entry.description)}" data-field="description">
|
|
204
200
|
</div>
|
|
205
201
|
<div class="field-pair">
|
|
206
202
|
<div class="field">
|
|
207
|
-
<label for="p-${esc(entry.id)}"
|
|
203
|
+
<label for="p-${esc(entry.id)}">${tr("field.project")}</label>
|
|
208
204
|
<select id="p-${esc(entry.id)}" data-field="projectId">${projects}</select>
|
|
209
205
|
</div>
|
|
210
206
|
<div class="field">
|
|
211
|
-
<label for="m-${esc(entry.id)}"
|
|
207
|
+
<label for="m-${esc(entry.id)}">${tr("field.minutes")}</label>
|
|
212
208
|
<input id="m-${esc(entry.id)}" type="number" min="0" step="1"
|
|
213
209
|
value="${Math.round(entry.seconds / 60)}" data-field="minutes">
|
|
214
210
|
</div>
|
|
@@ -216,7 +212,7 @@ function renderRowBody(entry) {
|
|
|
216
212
|
${commits}${ai}
|
|
217
213
|
<div class="row-actions">
|
|
218
214
|
<button type="button" class="chip ${entry.billable ? "is-on" : ""}" data-billable>
|
|
219
|
-
${entry.billable ? "
|
|
215
|
+
${entry.billable ? tr("row.billableOn") : tr("row.billableOff")}
|
|
220
216
|
</button>
|
|
221
217
|
</div>
|
|
222
218
|
</div>`;
|
|
@@ -236,31 +232,30 @@ function renderAddForm(day) {
|
|
|
236
232
|
|
|
237
233
|
return `
|
|
238
234
|
<div class="addwrap">
|
|
239
|
-
<button type="button" class="add-toggle" id="add-toggle"
|
|
235
|
+
<button type="button" class="add-toggle" id="add-toggle">${tr("add.toggle")}</button>
|
|
240
236
|
<form class="addform" id="add-form" hidden>
|
|
241
|
-
<p class="addform-hint">
|
|
242
|
-
Un import nunca las toca.</p>
|
|
237
|
+
<p class="addform-hint">${tr("add.hint")}</p>
|
|
243
238
|
<div class="addrow">
|
|
244
239
|
<label class="field">
|
|
245
|
-
<span
|
|
240
|
+
<span>${tr("add.kind")}</span>
|
|
246
241
|
<select id="add-kind">${kinds}</select>
|
|
247
242
|
</label>
|
|
248
243
|
<label class="field">
|
|
249
|
-
<span
|
|
244
|
+
<span>${tr("field.minutes")}</span>
|
|
250
245
|
<input id="add-minutes" type="number" min="5" step="5" value="60" required>
|
|
251
246
|
</label>
|
|
252
247
|
</div>
|
|
253
248
|
<label class="field">
|
|
254
|
-
<span
|
|
255
|
-
<input id="add-what" type="text" placeholder="
|
|
249
|
+
<span>${tr("field.what")}</span>
|
|
250
|
+
<input id="add-what" type="text" placeholder="${tr("add.whatPlaceholder")}">
|
|
256
251
|
</label>
|
|
257
252
|
<label class="field">
|
|
258
|
-
<span
|
|
253
|
+
<span>${tr("field.project")}</span>
|
|
259
254
|
<select id="add-project">${projects}</select>
|
|
260
255
|
</label>
|
|
261
256
|
<div class="addactions">
|
|
262
|
-
<button type="button" class="chip" id="add-cancel"
|
|
263
|
-
<button type="submit" class="btn-main"
|
|
257
|
+
<button type="button" class="chip" id="add-cancel">${tr("common.cancel")}</button>
|
|
258
|
+
<button type="submit" class="btn-main">${tr("add.submit")}</button>
|
|
264
259
|
</div>
|
|
265
260
|
</form>
|
|
266
261
|
</div>`;
|
|
@@ -294,7 +289,7 @@ function wireAddForm() {
|
|
|
294
289
|
}),
|
|
295
290
|
});
|
|
296
291
|
await loadDay();
|
|
297
|
-
toast("
|
|
292
|
+
toast(tr("toast.hoursAdded"));
|
|
298
293
|
} catch (error) {
|
|
299
294
|
toast(error.message);
|
|
300
295
|
}
|
|
@@ -338,12 +333,19 @@ async function save(id, patch) {
|
|
|
338
333
|
body: JSON.stringify(patch),
|
|
339
334
|
});
|
|
340
335
|
await loadDay();
|
|
341
|
-
toast("
|
|
336
|
+
toast(tr("toast.saved"));
|
|
342
337
|
} catch (error) {
|
|
343
338
|
toast(error.message);
|
|
344
339
|
}
|
|
345
340
|
}
|
|
346
341
|
|
|
342
|
+
/** "hoy", "ayer" o "hace N días", para decir cuándo se publicó un panel. */
|
|
343
|
+
function haceCuanto(days) {
|
|
344
|
+
if (days === 0) return tr("when.today");
|
|
345
|
+
if (days === 1) return tr("when.yesterday");
|
|
346
|
+
return tr("when.daysAgo", { n: days });
|
|
347
|
+
}
|
|
348
|
+
|
|
347
349
|
// ── Resumen: todos los proyectos a la vez ──────────────────────────────
|
|
348
350
|
|
|
349
351
|
async function loadSummary() {
|
|
@@ -355,50 +357,46 @@ async function loadSummary() {
|
|
|
355
357
|
function renderSummary() {
|
|
356
358
|
const s = state.summary;
|
|
357
359
|
$("#summary-range").textContent =
|
|
358
|
-
`${
|
|
359
|
-
`${s.activeDays} día${s.activeDays === 1 ? "" : "s"} trabajados`;
|
|
360
|
+
`${tr("summary.projects", { n: s.activeProjects })} · ${tr("summary.days", { n: s.activeDays })}`;
|
|
360
361
|
|
|
361
362
|
// Con varias monedas se enseñan todas: un guion parecería que falta un dato.
|
|
362
363
|
const money = (s.totals || []).length
|
|
363
364
|
? s.totals.map((t) => fmtMoney(t.amountMinor, t.currency)).join(" + ")
|
|
364
365
|
: null;
|
|
365
366
|
const moneyLabel = (s.totals || []).length > 1
|
|
366
|
-
? "
|
|
367
|
+
? tr("summary.valueByCurrency") : tr("summary.value");
|
|
367
368
|
|
|
368
369
|
$("#kpis").innerHTML = `
|
|
369
|
-
${kpi(fmtDuration(s.totalSeconds), "
|
|
370
|
+
${kpi(fmtDuration(s.totalSeconds), tr("summary.kpiHours"))}
|
|
370
371
|
${kpi(money || "—", moneyLabel, "kpi-money")}
|
|
371
372
|
${s.billableSeconds !== s.totalSeconds
|
|
372
|
-
? kpi(fmtDuration(s.billableSeconds), "
|
|
373
|
-
${kpi(String(s.pendingApproval),
|
|
373
|
+
? kpi(fmtDuration(s.billableSeconds), tr("summary.kpiBillable"), "kpi-quiet") : ""}
|
|
374
|
+
${kpi(String(s.pendingApproval), tr("summary.kpiPending", { n: s.pendingApproval }),
|
|
374
375
|
s.pendingApproval > 0 ? "kpi-attention" : "")}
|
|
375
|
-
${kpi("$" + (s.aiMicroUsd / 1e6).toFixed(2), "
|
|
376
|
+
${kpi("$" + (s.aiMicroUsd / 1e6).toFixed(2), tr("summary.kpiAi"), "kpi-quiet")}`;
|
|
376
377
|
|
|
377
378
|
const parts = [];
|
|
378
379
|
|
|
379
380
|
if (s.projects.length === 0) {
|
|
380
381
|
parts.push(`<div class="empty">
|
|
381
|
-
<h2
|
|
382
|
-
<p>
|
|
383
|
-
asignados a un proyecto.</p>
|
|
382
|
+
<h2>${tr("summary.empty.title")}</h2>
|
|
383
|
+
<p>${tr("summary.empty.body")}</p>
|
|
384
384
|
<code>estela status</code>
|
|
385
385
|
</div>`);
|
|
386
386
|
} else {
|
|
387
387
|
if (s.pendingApproval > 0) {
|
|
388
388
|
parts.push(`<div class="approve-all">
|
|
389
|
-
<p
|
|
390
|
-
|
|
391
|
-
histórico. Pulsa el aviso de cada proyecto para ir a ellos.</p>
|
|
392
|
-
<button type="button" class="btn-main" id="approve-all">Aprobar el periodo</button>
|
|
389
|
+
<p>${tr("summary.approveAll", { n: s.pendingApproval })}</p>
|
|
390
|
+
<button type="button" class="btn-main" id="approve-all">${tr("summary.approveAll.btn")}</button>
|
|
393
391
|
</div>`);
|
|
394
392
|
}
|
|
395
393
|
parts.push(renderSparkline(s.byDay));
|
|
396
|
-
parts.push(`<h2 class="block-title"
|
|
394
|
+
parts.push(`<h2 class="block-title">${tr("tab.projects")}</h2>`);
|
|
397
395
|
parts.push(`<div class="plist">${s.projects.map(renderProjectRow).join("")}</div>`);
|
|
398
396
|
}
|
|
399
397
|
|
|
400
398
|
if (s.activity.length) {
|
|
401
|
-
parts.push(`<h2 class="block-title"
|
|
399
|
+
parts.push(`<h2 class="block-title">${tr("summary.activity")}</h2>`);
|
|
402
400
|
parts.push(`<div class="feed">${s.activity.map(renderActivity).join("")}</div>`);
|
|
403
401
|
}
|
|
404
402
|
|
|
@@ -411,7 +409,7 @@ function renderSummary() {
|
|
|
411
409
|
method: "POST", body: JSON.stringify({ from, to }),
|
|
412
410
|
});
|
|
413
411
|
await loadSummary();
|
|
414
|
-
toast(
|
|
412
|
+
toast(tr("toast.blocksApproved", { n: r.approved }));
|
|
415
413
|
} catch (error) { toast(error.message); }
|
|
416
414
|
});
|
|
417
415
|
|
|
@@ -422,13 +420,13 @@ function renderSummary() {
|
|
|
422
420
|
btn.addEventListener("click", async () => {
|
|
423
421
|
try {
|
|
424
422
|
const r = await api(`/api/pending-days?project=${encodeURIComponent(btn.dataset.pending)}`);
|
|
425
|
-
if (!r.days.length) { toast("
|
|
423
|
+
if (!r.days.length) { toast(tr("summary.nothingLeft")); return; }
|
|
426
424
|
state.date = r.days[0].date;
|
|
427
425
|
showView("day");
|
|
428
426
|
const otros = r.days.length - 1;
|
|
429
427
|
toast(otros > 0
|
|
430
|
-
?
|
|
431
|
-
:
|
|
428
|
+
? tr("summary.pendingHere", { blocks: r.days[0].blocks, more: tr("summary.moreDays", { n: otros }) })
|
|
429
|
+
: tr("summary.lastPending", { blocks: r.days[0].blocks }));
|
|
432
430
|
} catch (error) { toast(error.message); }
|
|
433
431
|
});
|
|
434
432
|
});
|
|
@@ -465,29 +463,29 @@ function renderSparkline(byDay) {
|
|
|
465
463
|
*/
|
|
466
464
|
function renderCompartir(projectId, pub) {
|
|
467
465
|
const url = (pub.baseUrl || "https://getestela.dev") + "/e/" + pub.token + "/";
|
|
468
|
-
const texto = encodeURIComponent("
|
|
466
|
+
const texto = encodeURIComponent(tr("share.message", { url }));
|
|
469
467
|
const clientes = state.panelClients?.[pub.token] || [];
|
|
470
468
|
|
|
471
469
|
return `
|
|
472
470
|
<div class="share-ways" data-token="${esc(pub.token)}" data-url="${esc(url)}">
|
|
473
471
|
<div class="share-row">
|
|
474
|
-
<button type="button" class="chip" data-copy-url
|
|
472
|
+
<button type="button" class="chip" data-copy-url>${tr("share.copy")}</button>
|
|
475
473
|
<a class="chip" href="https://wa.me/?text=${texto}" target="_blank" rel="noopener">WhatsApp</a>
|
|
476
|
-
<a class="chip" href="mailto:?subject=${encodeURIComponent("
|
|
474
|
+
<a class="chip" href="mailto:?subject=${encodeURIComponent(tr("share.subject"))}&body=${texto}">${tr("share.email")}</a>
|
|
477
475
|
</div>
|
|
478
476
|
<label class="share-label" for="cl-${esc(projectId)}">
|
|
479
|
-
|
|
477
|
+
${tr("share.byEmail")}
|
|
480
478
|
</label>
|
|
481
479
|
<div class="share-row">
|
|
482
480
|
<input id="cl-${esc(projectId)}" class="share-mails" type="text"
|
|
483
|
-
placeholder="
|
|
481
|
+
placeholder="${tr("share.emailsPlaceholder")}"
|
|
484
482
|
value="${esc(clientes.map((c) => c.email).join(", "))}">
|
|
485
|
-
<button type="button" class="chip" data-save-clients
|
|
483
|
+
<button type="button" class="chip" data-save-clients>${tr("common.save")}</button>
|
|
486
484
|
</div>
|
|
487
485
|
${clientes.length ? `<ul class="share-who">${clientes.map((c) =>
|
|
488
486
|
`<li>${esc(c.email)} — ${c.entrado
|
|
489
|
-
? "
|
|
490
|
-
: "
|
|
487
|
+
? tr("share.hasAccount")
|
|
488
|
+
: tr("share.notYet")}</li>`).join("")}</ul>` : ""}
|
|
491
489
|
<div class="share-msg"></div>
|
|
492
490
|
</div>`;
|
|
493
491
|
}
|
|
@@ -500,8 +498,8 @@ function renderProjectRow(p) {
|
|
|
500
498
|
// por día a ciegas. El aviso es el propio camino.
|
|
501
499
|
const flag = p.pendingApproval > 0
|
|
502
500
|
? `<button type="button" class="tag tag-warn tag-go" data-pending="${esc(p.projectId)}"
|
|
503
|
-
title="
|
|
504
|
-
const KIND_TAG = { employment: "
|
|
501
|
+
title="${tr("project.pendingTitle")}">${tr("project.pendingTag", { n: p.pendingApproval })}</button>` : "";
|
|
502
|
+
const KIND_TAG = { employment: tr("kind.employment"), internal: tr("kind.internal") };
|
|
505
503
|
const notBillable = KIND_TAG[p.kind] ? `<span class="tag">${KIND_TAG[p.kind]}</span>` : "";
|
|
506
504
|
|
|
507
505
|
return `
|
|
@@ -509,7 +507,7 @@ function renderProjectRow(p) {
|
|
|
509
507
|
<div class="prow-text">
|
|
510
508
|
<div class="prow-name">${esc(p.projectName)} ${flag}${notBillable}</div>
|
|
511
509
|
<div class="prow-meta">${esc(p.clientName)}${rate ? ` · ${rate}/h` : ""} ·
|
|
512
|
-
${
|
|
510
|
+
${tr("project.days", { n: p.days })} · ${tr("project.lastSeen", { date: esc(p.lastDay) })}</div>
|
|
513
511
|
</div>
|
|
514
512
|
<div class="prow-figs">
|
|
515
513
|
<div class="prow-hours">${fmtDuration(p.seconds)}</div>
|
|
@@ -519,7 +517,7 @@ function renderProjectRow(p) {
|
|
|
519
517
|
}
|
|
520
518
|
|
|
521
519
|
function renderActivity(c) {
|
|
522
|
-
const when = new Date(c.at).toLocaleDateString(
|
|
520
|
+
const when = new Date(c.at).toLocaleDateString(localeTag(), { day: "numeric", month: "short" });
|
|
523
521
|
return `
|
|
524
522
|
<div class="act">
|
|
525
523
|
<code>${esc(c.hash)}</code>
|
|
@@ -560,8 +558,7 @@ function renderTeam() {
|
|
|
560
558
|
const people = t.people.length;
|
|
561
559
|
|
|
562
560
|
$("#team-sub").textContent =
|
|
563
|
-
`${people
|
|
564
|
-
`${t.activeDays} día${t.activeDays === 1 ? "" : "s"} con actividad`;
|
|
561
|
+
`${tr("team.people", { n: people })} · ${tr("team.activeDays", { n: t.activeDays })}`;
|
|
565
562
|
|
|
566
563
|
fillProjectFilter(t.projects);
|
|
567
564
|
$("#team-alerts").innerHTML = renderAlerts(t);
|
|
@@ -569,18 +566,18 @@ function renderTeam() {
|
|
|
569
566
|
|
|
570
567
|
const parts = [];
|
|
571
568
|
if (t.projects.length) {
|
|
572
|
-
parts.push(`<h2 class="block-title"
|
|
569
|
+
parts.push(`<h2 class="block-title">${tr("team.byProject")}</h2>`);
|
|
573
570
|
parts.push(`<div class="tbl-wrap"><table class="tbl">
|
|
574
571
|
<thead><tr>
|
|
575
|
-
<th
|
|
576
|
-
<th class="num"
|
|
577
|
-
<th class="num"
|
|
572
|
+
<th>${tr("team.th.project")}</th><th class="th-comp">${tr("team.th.composition")}</th>
|
|
573
|
+
<th class="num">${tr("team.th.hours")}</th><th class="num">${tr("team.th.aiCost")}</th>
|
|
574
|
+
<th class="num">${tr("team.th.value")}</th><th>${tr("team.th.status")}</th>
|
|
578
575
|
</tr></thead>
|
|
579
576
|
<tbody>${t.projects.map(teamProjectRow).join("")}</tbody></table></div>`);
|
|
580
577
|
parts.push(`<p class="legend">
|
|
581
|
-
<span class="sw sw-agent"></span>
|
|
582
|
-
<span class="sw sw-commit"></span>
|
|
583
|
-
<span class="sw sw-manual"></span>
|
|
578
|
+
<span class="sw sw-agent"></span> ${tr("team.legend.agent")}
|
|
579
|
+
<span class="sw sw-commit"></span> ${tr("team.legend.commit")}
|
|
580
|
+
<span class="sw sw-manual"></span> ${tr("team.legend.manual")}</p>`);
|
|
584
581
|
}
|
|
585
582
|
|
|
586
583
|
if (people) {
|
|
@@ -594,30 +591,27 @@ function renderTeam() {
|
|
|
594
591
|
// los totales son tu trabajo imputado, y aquí hay horas estimadas de gente
|
|
595
592
|
// que no usa Estela. Sin esta nota, el primer lector atento encuentra que
|
|
596
593
|
// los números no cuadran y deja de creerse toda la pantalla.
|
|
597
|
-
parts.push(`<h2 class="block-title"
|
|
598
|
-
<span class="block-note"
|
|
599
|
-
parts.push(`<p class="note-inline"
|
|
600
|
-
desde sus commits y <b>no entran</b> en los totales de arriba, que cuentan
|
|
601
|
-
solo tu trabajo imputado. Si alguien instala Estela, su fila pasa a
|
|
602
|
-
medida.</p>`);
|
|
594
|
+
parts.push(`<h2 class="block-title">${tr("team.peopleTitle")}
|
|
595
|
+
<span class="block-note">${tr("team.byRecent")}</span></h2>`);
|
|
596
|
+
parts.push(`<p class="note-inline">${tr("team.note")}</p>`);
|
|
603
597
|
parts.push(`<div class="tbl-wrap"><table class="tbl">
|
|
604
598
|
<thead><tr>
|
|
605
|
-
<th
|
|
606
|
-
<th
|
|
599
|
+
<th>${tr("team.th.person")}</th><th class="num">${tr("team.th.hours")}</th><th>${tr("team.th.precision")}</th>
|
|
600
|
+
<th>${tr("team.th.branches")}</th><th class="num">${tr("team.th.lastSeen")}</th>
|
|
607
601
|
</tr></thead>
|
|
608
602
|
<tbody>${shown.map(personRow).join("")}</tbody></table></div>`);
|
|
609
603
|
|
|
610
604
|
if (hidden > 0) {
|
|
611
605
|
parts.push(`<button type="button" class="more" id="team-more">
|
|
612
|
-
|
|
606
|
+
${tr("team.showMore", { n: hidden })}</button>`);
|
|
613
607
|
} else if (state.teamAll && t.people.length > TEAM_ROWS) {
|
|
614
|
-
parts.push(`<button type="button" class="more" id="team-more"
|
|
608
|
+
parts.push(`<button type="button" class="more" id="team-more">${tr("common.showLess")}</button>`);
|
|
615
609
|
}
|
|
616
610
|
}
|
|
617
611
|
|
|
618
612
|
if (!t.projects.length && !people) {
|
|
619
|
-
parts.push(`<div class="empty"><h2
|
|
620
|
-
<p
|
|
613
|
+
parts.push(`<div class="empty"><h2>${tr("summary.empty.title")}</h2>
|
|
614
|
+
<p>${tr("team.empty.body")}</p></div>`);
|
|
621
615
|
}
|
|
622
616
|
|
|
623
617
|
$("#team-body").innerHTML = parts.join("");
|
|
@@ -636,22 +630,26 @@ function renderAlerts(t) {
|
|
|
636
630
|
if (p.budgetPct !== null && p.budgetPct >= 80) {
|
|
637
631
|
const over = p.budgetPct >= 100;
|
|
638
632
|
out.push(alertBox(over ? "danger" : "warn",
|
|
639
|
-
|
|
640
|
-
|
|
633
|
+
over
|
|
634
|
+
? tr("team.alert.budgetOver", { project: p.projectName })
|
|
635
|
+
: tr("team.alert.budgetNear", { project: p.projectName, pct: p.budgetPct }),
|
|
636
|
+
tr("team.alert.budgetDetail", {
|
|
637
|
+
spent: "$" + (p.aiMicroUsd / 1e6).toFixed(2),
|
|
638
|
+
budget: "$" + (p.budgetMicroUsd / 1e6).toFixed(2),
|
|
639
|
+
})));
|
|
641
640
|
}
|
|
642
641
|
}
|
|
643
642
|
|
|
644
643
|
const stale = t.openWork.filter((w) => w.ageDays >= 14);
|
|
645
644
|
if (stale.length) {
|
|
646
645
|
out.push(alertBox("warn",
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
`Puede ser trabajo parado o una revisión pendiente en el otro lado.`));
|
|
646
|
+
tr("team.alert.stale", { n: stale.length }),
|
|
647
|
+
tr("team.alert.staleDetail", { name: stale[0].name, days: stale[0].ageDays })));
|
|
650
648
|
}
|
|
651
649
|
|
|
652
650
|
if (t.pendingApproval > 0) {
|
|
653
|
-
out.push(alertBox("info",
|
|
654
|
-
"
|
|
651
|
+
out.push(alertBox("info", tr("team.alert.pending", { n: t.pendingApproval }),
|
|
652
|
+
tr("team.alert.pendingDetail")));
|
|
655
653
|
}
|
|
656
654
|
return out.join("");
|
|
657
655
|
}
|
|
@@ -667,8 +665,8 @@ function renderTeamKpis(t) {
|
|
|
667
665
|
const hours = fmtDuration(t.totalSeconds);
|
|
668
666
|
const delta = t.totalSeconds - t.previousSeconds;
|
|
669
667
|
const deltaTxt = t.previousSeconds > 0
|
|
670
|
-
? `${delta >= 0 ? "+" : "−"}${fmtDuration(Math.abs(delta))}
|
|
671
|
-
: "
|
|
668
|
+
? tr("team.kpi.delta", { delta: `${delta >= 0 ? "+" : "−"}${fmtDuration(Math.abs(delta))}` })
|
|
669
|
+
: tr("team.kpi.noPrevious");
|
|
672
670
|
|
|
673
671
|
const ai = t.aiMicroUsd / 1e6;
|
|
674
672
|
const perHour = t.totalSeconds > 0 ? (ai / (t.totalSeconds / 3600)) : 0;
|
|
@@ -680,13 +678,13 @@ function renderTeamKpis(t) {
|
|
|
680
678
|
const oldest = open ? t.openWork[0] : null;
|
|
681
679
|
|
|
682
680
|
return [
|
|
683
|
-
teamKpi(hours, "
|
|
684
|
-
teamKpi("$" + ai.toFixed(2), "
|
|
685
|
-
|
|
686
|
-
teamKpi(pct + "%", "
|
|
687
|
-
|
|
688
|
-
teamKpi(String(open),
|
|
689
|
-
oldest ?
|
|
681
|
+
teamKpi(hours, tr("team.kpi.hours"), deltaTxt, delta >= 0 ? "up" : "down"),
|
|
682
|
+
teamKpi("$" + ai.toFixed(2), tr("team.kpi.aiCost"),
|
|
683
|
+
tr("team.kpi.perHour", { amount: "$" + perHour.toFixed(2) })),
|
|
684
|
+
teamKpi(pct + "%", tr("team.kpi.billable"),
|
|
685
|
+
tr("team.kpi.ofTotal", { part: fmtDuration(t.billableSeconds), total: hours })),
|
|
686
|
+
teamKpi(String(open), tr("team.kpi.unmerged", { n: open }),
|
|
687
|
+
oldest ? tr("team.kpi.oldest", { n: oldest.ageDays }) : tr("team.kpi.allMerged"),
|
|
690
688
|
open > 0 ? "warn" : ""),
|
|
691
689
|
].join("");
|
|
692
690
|
}
|
|
@@ -707,7 +705,9 @@ function compositionBar(c) {
|
|
|
707
705
|
if (!total) return "";
|
|
708
706
|
const seg = (n, cls) => n > 0
|
|
709
707
|
? `<span class="seg ${cls}" style="width:${(n / total) * 100}%"></span>` : "";
|
|
710
|
-
const title =
|
|
708
|
+
const title = tr("team.composition", {
|
|
709
|
+
agent: fmtDuration(c.agent), commit: fmtDuration(c.commit), manual: fmtDuration(c.manual),
|
|
710
|
+
});
|
|
711
711
|
return `<div class="comp" title="${esc(title)}">
|
|
712
712
|
${seg(c.agent, "sw-agent")}${seg(c.commit, "sw-commit")}${seg(c.manual, "sw-manual")}
|
|
713
713
|
</div>`;
|
|
@@ -715,11 +715,11 @@ function compositionBar(c) {
|
|
|
715
715
|
|
|
716
716
|
function teamProjectRow(p) {
|
|
717
717
|
const STATE = {
|
|
718
|
-
employment: ["
|
|
719
|
-
internal: ["
|
|
718
|
+
employment: [tr("kind.employment"), "tag"],
|
|
719
|
+
internal: [tr("kind.internal"), "tag"],
|
|
720
720
|
};
|
|
721
721
|
const st = STATE[p.kind] ??
|
|
722
|
-
(p.pendingApproval > 0 ? ["
|
|
722
|
+
(p.pendingApproval > 0 ? [tr("team.status.pending"), "tag tag-warn"] : [tr("team.status.ready"), "tag tag-ok"]);
|
|
723
723
|
|
|
724
724
|
return `<tr>
|
|
725
725
|
<td><div class="td-name">${esc(p.projectName)}</div>
|
|
@@ -739,10 +739,10 @@ function personRow(p) {
|
|
|
739
739
|
: `<span class="td-sub">—</span>`;
|
|
740
740
|
|
|
741
741
|
return `<tr>
|
|
742
|
-
<td><div class="td-name">${esc(p.name)}${p.isMe ?
|
|
742
|
+
<td><div class="td-name">${esc(p.name)}${p.isMe ? ` <span class="tag">${tr("team.you")}</span>` : ""}</div>
|
|
743
743
|
<div class="td-sub">${esc(p.emails[0] || "")}</div></td>
|
|
744
744
|
<td class="num">${fmtDuration(p.seconds)}</td>
|
|
745
|
-
<td><span class="prec ${p.measured ? "prec-ok" : ""}">${p.measured ? "
|
|
745
|
+
<td><span class="prec ${p.measured ? "prec-ok" : ""}">${p.measured ? tr("team.measured") : tr("team.estimated")}</span></td>
|
|
746
746
|
<td>${branches}</td>
|
|
747
747
|
<td class="num td-sub">${esc(p.lastDay || "—")}</td>
|
|
748
748
|
</tr>`;
|
|
@@ -752,7 +752,7 @@ function fillProjectFilter(projects) {
|
|
|
752
752
|
const sel = $("#team-project");
|
|
753
753
|
if (sel.dataset.filled === String(projects.length) && sel.value === state.teamProject) return;
|
|
754
754
|
const seen = state.team.projects.map((p) => [p.projectId, p.projectName]);
|
|
755
|
-
sel.innerHTML = `<option value=""
|
|
755
|
+
sel.innerHTML = `<option value="">${tr("team.allProjects")}</option>` +
|
|
756
756
|
seen.map(([id, name]) => `<option value="${esc(id)}">${esc(name)}</option>`).join("");
|
|
757
757
|
sel.value = state.teamProject;
|
|
758
758
|
sel.dataset.filled = String(projects.length);
|
|
@@ -803,20 +803,15 @@ function renderProjects(data, orphans) {
|
|
|
803
803
|
const atrasados = (data.publications || []).filter((p) => p.staleBlocks > 0);
|
|
804
804
|
if (atrasados.length) {
|
|
805
805
|
parts.push(`<div class="stale">
|
|
806
|
-
<p><b>${
|
|
807
|
-
|
|
808
|
-
Tu cliente está viendo datos anteriores a tu último trabajo. Esto no
|
|
809
|
-
tiene que ver con los bloques por revisar: aquí solo salen los
|
|
810
|
-
proyectos que has compartido, y cuenta lo que cambió desde que lo
|
|
811
|
-
publicaste, no lo que te falta por aprobar.</p>
|
|
806
|
+
<p><b>${tr("projects.stale.head", { n: atrasados.length })}</b>
|
|
807
|
+
${tr("projects.stale.body")}</p>
|
|
812
808
|
<ul>${atrasados.map((p) => {
|
|
813
809
|
const nombre = (data.projects.find((x) => x.id === p.projectId) || {}).name || p.projectId;
|
|
814
|
-
return `<li>${
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
p.daysAgo === 1 ? "ayer" : "hace " + p.daysAgo + " días"}</li>`;
|
|
810
|
+
return `<li>${tr("projects.stale.item", {
|
|
811
|
+
name: esc(nombre), blocks: tr("blocks.new", { n: p.staleBlocks }), when: haceCuanto(p.daysAgo),
|
|
812
|
+
})}</li>`;
|
|
818
813
|
}).join("")}</ul>
|
|
819
|
-
<p class="stale-fix"
|
|
814
|
+
<p class="stale-fix">${tr("projects.stale.fix")}</p>
|
|
820
815
|
</div>`);
|
|
821
816
|
}
|
|
822
817
|
|
|
@@ -825,7 +820,7 @@ function renderProjects(data, orphans) {
|
|
|
825
820
|
const rate = p.kind === "client" ? fmtMoney(p.hourlyMinor, p.currency) : null;
|
|
826
821
|
const history = p.rateHistory.length > 1
|
|
827
822
|
? `<div class="rate-hist">${p.rateHistory.map((r) =>
|
|
828
|
-
`<span>${esc(fmtMoney(r.minor, r.currency))
|
|
823
|
+
`<span>${tr("rate.historyItem", { amount: esc(fmtMoney(r.minor, r.currency)), date: esc(r.from) })}</span>`).join("")}</div>`
|
|
829
824
|
: "";
|
|
830
825
|
|
|
831
826
|
parts.push(`
|
|
@@ -833,59 +828,56 @@ function renderProjects(data, orphans) {
|
|
|
833
828
|
<div class="pcard-head">
|
|
834
829
|
<div>
|
|
835
830
|
<div class="pcard-name">${esc(p.name)}</div>
|
|
836
|
-
<div class="pcard-meta">${
|
|
831
|
+
<div class="pcard-meta">${tr("projects.meta", {
|
|
832
|
+
client: esc(p.clientName), duration: fmtDuration(p.seconds), blocks: tr("blocks", { n: p.blocks }),
|
|
833
|
+
})}</div>
|
|
837
834
|
</div>
|
|
838
835
|
<div class="pcard-rate">
|
|
839
|
-
<div class="pcard-rate-v">${rate ? esc(rate) + "/h" : "
|
|
836
|
+
<div class="pcard-rate-v">${rate ? esc(rate) + "/h" : tr("row.noRate")}</div>
|
|
840
837
|
</div>
|
|
841
838
|
</div>
|
|
842
839
|
${p.repoPaths.length
|
|
843
840
|
? `<div class="repos">${p.repoPaths.map((r) => `<code>${esc(r)}</code>`).join("")}</div>`
|
|
844
841
|
: `<div class="pcard-warn">
|
|
845
|
-
<p
|
|
842
|
+
<p>${tr("projects.noRepo")}</p>
|
|
846
843
|
${orphans.length
|
|
847
844
|
? `<div class="row-inline">
|
|
848
845
|
<select class="repo-pick" data-project="${esc(p.id)}">
|
|
849
846
|
${orphans.slice(0, 40).map((r) =>
|
|
850
|
-
`<option value="${esc(r.path)}">${esc(r.name)
|
|
847
|
+
`<option value="${esc(r.path)}">${tr("projects.orphanOption", { name: esc(r.name), turns: esc(r.turns), last: esc(r.lastAt) })}</option>`).join("")}
|
|
851
848
|
</select>
|
|
852
|
-
<button type="button" class="chip repo-link" data-project="${esc(p.id)}"
|
|
849
|
+
<button type="button" class="chip repo-link" data-project="${esc(p.id)}">${tr("projects.link")}</button>
|
|
853
850
|
</div>`
|
|
854
|
-
: `<p class="addform-hint"
|
|
855
|
-
Trabaja un rato con tu agente y vuelve, o vincúlalo con
|
|
856
|
-
<code>estela team repo --project ${esc(p.id)} --add <ruta></code>.</p>`}
|
|
851
|
+
: `<p class="addform-hint">${tr("projects.noOrphans", { id: esc(p.id) })}</p>`}
|
|
857
852
|
</div>`}
|
|
858
853
|
${history}
|
|
859
854
|
<div class="share-block">
|
|
860
855
|
<button type="button" class="chip share-btn${pubs.get(p.id) && pubs.get(p.id).staleBlocks > 0 ? " is-stale" : ""}">
|
|
861
|
-
${pubs.has(p.id) ? "
|
|
856
|
+
${pubs.has(p.id) ? tr("share.update") : tr("share.share")}
|
|
862
857
|
</button>
|
|
863
858
|
<span class="share-hint">${
|
|
864
|
-
!pubs.has(p.id) ? "
|
|
859
|
+
!pubs.has(p.id) ? tr("share.hintNew")
|
|
865
860
|
: pubs.get(p.id).staleBlocks > 0
|
|
866
|
-
?
|
|
867
|
-
:
|
|
868
|
-
pubs.get(p.id).daysAgo === 0 ? "hoy" :
|
|
869
|
-
pubs.get(p.id).daysAgo === 1 ? "ayer" :
|
|
870
|
-
"hace " + pubs.get(p.id).daysAgo + " días"} · al día.`
|
|
861
|
+
? tr("share.hintStale", { blocks: tr("blocks.new", { n: pubs.get(p.id).staleBlocks }) })
|
|
862
|
+
: tr("share.hintFresh", { when: haceCuanto(pubs.get(p.id).daysAgo) })
|
|
871
863
|
}</span>
|
|
872
864
|
<div class="share-out" hidden></div>
|
|
873
865
|
${pubs.has(p.id) ? renderCompartir(p.id, pubs.get(p.id)) : ""}
|
|
874
866
|
</div>
|
|
875
867
|
<form class="rateform">
|
|
876
|
-
<span
|
|
868
|
+
<span>${tr("rate.changeTo")}</span>
|
|
877
869
|
<input type="number" class="rate-new" min="0" step="0.5"
|
|
878
870
|
value="${p.hourlyMinor ? (p.hourlyMinor / 100) : ""}" placeholder="45">
|
|
879
|
-
<span>${esc(p.currency)}
|
|
871
|
+
<span>${tr("rate.from", { currency: esc(p.currency) })}</span>
|
|
880
872
|
<input type="date" class="rate-from" value="${localToday()}">
|
|
881
|
-
<button type="submit" class="chip"
|
|
873
|
+
<button type="submit" class="chip">${tr("common.save")}</button>
|
|
882
874
|
</form>
|
|
883
875
|
</div>`);
|
|
884
876
|
}
|
|
885
877
|
|
|
886
878
|
if (!data.projects.length) {
|
|
887
|
-
parts.push(`<div class="empty"><h2
|
|
888
|
-
<p
|
|
879
|
+
parts.push(`<div class="empty"><h2>${tr("projects.empty.title")}</h2>
|
|
880
|
+
<p>${tr("projects.empty.body")}</p></div>`);
|
|
889
881
|
}
|
|
890
882
|
|
|
891
883
|
// Repos con trabajo capturado que no pertenecen a nadie. Es la lista más
|
|
@@ -895,51 +887,48 @@ function renderProjects(data, orphans) {
|
|
|
895
887
|
const orphanCards = orphans.slice(0, 12).map((r) => `
|
|
896
888
|
<button type="button" class="orphan" data-path="${esc(r.path)}" data-name="${esc(r.name)}">
|
|
897
889
|
<span class="orphan-name">${esc(r.name)}</span>
|
|
898
|
-
<span class="orphan-meta">${r.turns
|
|
890
|
+
<span class="orphan-meta">${tr("projects.orphanMeta", { turns: r.turns, last: esc(r.lastAt) })}</span>
|
|
899
891
|
</button>`).join("");
|
|
900
892
|
|
|
901
893
|
const orphanOptions = orphans.slice(0, 40)
|
|
902
|
-
.map((r) => `<option value="${esc(r.path)}">${esc(r.name)
|
|
894
|
+
.map((r) => `<option value="${esc(r.path)}">${tr("projects.orphanOption", { name: esc(r.name), turns: r.turns, last: esc(r.lastAt) })}</option>`)
|
|
903
895
|
.join("");
|
|
904
896
|
|
|
905
897
|
const clientOptions = data.clients
|
|
906
898
|
.map((c) => `<option value="${esc(c.id)}">${esc(c.name)} (${esc(c.currency)})</option>`).join("");
|
|
907
899
|
|
|
908
900
|
parts.push(`
|
|
909
|
-
<h2 class="block-title"
|
|
901
|
+
<h2 class="block-title">${tr("newProject.title")}</h2>
|
|
910
902
|
<form class="newproject" id="new-project">
|
|
911
|
-
${orphans.length ? `<p class="addform-hint"
|
|
912
|
-
capturado y sin proyecto. Son horas que ya registraste pero todavía no puedes informar.
|
|
913
|
-
Pulsa uno para empezar:</p>
|
|
903
|
+
${orphans.length ? `<p class="addform-hint">${tr("newProject.orphans", { n: orphans.length })}</p>
|
|
914
904
|
<div class="orphans">${orphanCards}</div>` : ""}
|
|
915
905
|
<label class="field">
|
|
916
|
-
<span
|
|
906
|
+
<span>${tr("newProject.repo")}</span>
|
|
917
907
|
<select id="np-repo">
|
|
918
|
-
<option value=""
|
|
908
|
+
<option value="">${tr("newProject.pickRepo")}</option>
|
|
919
909
|
${orphanOptions}
|
|
920
910
|
</select>
|
|
921
911
|
</label>
|
|
922
912
|
<div class="addrow">
|
|
923
|
-
<label class="field"><span
|
|
924
|
-
<input id="np-name" type="text" placeholder="
|
|
925
|
-
<label class="field"><span
|
|
913
|
+
<label class="field"><span>${tr("newProject.name")}</span>
|
|
914
|
+
<input id="np-name" type="text" placeholder="${tr("newProject.namePlaceholder")}" required></label>
|
|
915
|
+
<label class="field"><span>${tr("newProject.rate")}</span>
|
|
926
916
|
<input id="np-rate" type="number" min="0" step="0.5" placeholder="45"></label>
|
|
927
917
|
</div>
|
|
928
918
|
<div class="addrow">
|
|
929
|
-
<label class="field"><span
|
|
930
|
-
<select id="np-client"><option value=""
|
|
931
|
-
<label class="field"><span
|
|
932
|
-
<input id="np-clientname" type="text" placeholder="
|
|
933
|
-
<label class="field np-cur"><span
|
|
919
|
+
<label class="field"><span>${tr("newProject.client")}</span>
|
|
920
|
+
<select id="np-client"><option value="">${tr("newProject.newClientOption")}</option>${clientOptions}</select></label>
|
|
921
|
+
<label class="field"><span>${tr("newProject.newClient")}</span>
|
|
922
|
+
<input id="np-clientname" type="text" placeholder="${tr("newProject.clientPlaceholder")}"></label>
|
|
923
|
+
<label class="field np-cur"><span>${tr("newProject.currency")}</span>
|
|
934
924
|
<select id="np-currency">
|
|
935
925
|
<option>EUR</option><option>USD</option><option>GBP</option>
|
|
936
926
|
<option>MXN</option><option>COP</option><option>BRL</option>
|
|
937
927
|
</select></label>
|
|
938
928
|
</div>
|
|
939
|
-
<div class="addactions"><button type="submit" class="btn-main"
|
|
929
|
+
<div class="addactions"><button type="submit" class="btn-main">${tr("newProject.submit")}</button></div>
|
|
940
930
|
</form>
|
|
941
|
-
<p class="note"
|
|
942
|
-
trabajo ya capturado de ese repositorio se impute.</p>`);
|
|
931
|
+
<p class="note">${tr("newProject.note")}</p>`);
|
|
943
932
|
|
|
944
933
|
$("#projects-body").innerHTML = parts.join("");
|
|
945
934
|
wireProjects();
|
|
@@ -959,8 +948,7 @@ async function askAuthor(projectId, repoPath) {
|
|
|
959
948
|
const box = document.createElement("div");
|
|
960
949
|
box.className = "authorbox";
|
|
961
950
|
box.innerHTML =
|
|
962
|
-
`<p class="addform-hint"
|
|
963
|
-
Si eliges mal, se capturarán los commits de otra persona.</p>` +
|
|
951
|
+
`<p class="addform-hint">${tr("author.ask")}</p>` +
|
|
964
952
|
authors.slice(0, 8).map((a) =>
|
|
965
953
|
`<button type="button" class="chip author-pick" data-email="${esc(a.email)}">
|
|
966
954
|
${esc(a.email)} <span class="author-n">${a.commits}</span>
|
|
@@ -975,7 +963,7 @@ async function askAuthor(projectId, repoPath) {
|
|
|
975
963
|
body: JSON.stringify({ projectId, emails: btn.dataset.email }),
|
|
976
964
|
});
|
|
977
965
|
await loadProjects();
|
|
978
|
-
toast("
|
|
966
|
+
toast(tr("toast.identitySaved"));
|
|
979
967
|
});
|
|
980
968
|
});
|
|
981
969
|
}
|
|
@@ -1019,7 +1007,7 @@ function wireProjects() {
|
|
|
1019
1007
|
}),
|
|
1020
1008
|
});
|
|
1021
1009
|
await loadProjects();
|
|
1022
|
-
toast("
|
|
1010
|
+
toast(tr("toast.projectCreated"));
|
|
1023
1011
|
// El correo de commit es lo primero que hay que configurar: sin él se
|
|
1024
1012
|
// capturan los commits equivocados, o casi ninguno.
|
|
1025
1013
|
if ($("#np-repo").value) await askAuthor(nuevo.id, $("#np-repo").value);
|
|
@@ -1041,8 +1029,8 @@ function wireProjects() {
|
|
|
1041
1029
|
body: JSON.stringify({ projectId, repoPath: sel.value }),
|
|
1042
1030
|
});
|
|
1043
1031
|
toast(r.importing
|
|
1044
|
-
? "
|
|
1045
|
-
: (r.blocks ?
|
|
1032
|
+
? tr("toast.linkedImporting")
|
|
1033
|
+
: (r.blocks ? tr("toast.linkedBlocks", { blocks: tr("blocks", { n: r.blocks }) }) : tr("toast.repoLinked")));
|
|
1046
1034
|
await loadProjects();
|
|
1047
1035
|
} catch (error) { toast(error.message); btn.disabled = false; }
|
|
1048
1036
|
});
|
|
@@ -1051,12 +1039,12 @@ function wireProjects() {
|
|
|
1051
1039
|
document.querySelectorAll("[data-copy-url]").forEach((btn) => {
|
|
1052
1040
|
btn.addEventListener("click", async () => {
|
|
1053
1041
|
const url = btn.closest(".share-ways").dataset.url;
|
|
1054
|
-
try { await navigator.clipboard.writeText(url); toast("
|
|
1042
|
+
try { await navigator.clipboard.writeText(url); toast(tr("toast.linkCopied")); }
|
|
1055
1043
|
catch {
|
|
1056
1044
|
const ta = document.createElement("textarea");
|
|
1057
1045
|
ta.value = url; document.body.appendChild(ta); ta.select();
|
|
1058
|
-
try { document.execCommand("copy"); toast("
|
|
1059
|
-
catch { toast("
|
|
1046
|
+
try { document.execCommand("copy"); toast(tr("toast.linkCopied")); }
|
|
1047
|
+
catch { toast(tr("toast.copyFailed")); }
|
|
1060
1048
|
document.body.removeChild(ta);
|
|
1061
1049
|
}
|
|
1062
1050
|
});
|
|
@@ -1076,11 +1064,11 @@ function wireProjects() {
|
|
|
1076
1064
|
method: "POST",
|
|
1077
1065
|
body: JSON.stringify({ token: caja.dataset.token, clients }),
|
|
1078
1066
|
});
|
|
1079
|
-
toast(clients.length ? "
|
|
1067
|
+
toast(clients.length ? tr("toast.sharedByEmail") : tr("toast.sharedWithNobody"));
|
|
1080
1068
|
await loadProjects();
|
|
1081
1069
|
} catch (error) {
|
|
1082
1070
|
msg.textContent = error.body?.needsLogin
|
|
1083
|
-
? "
|
|
1071
|
+
? tr("needsLogin.cmd")
|
|
1084
1072
|
: error.message;
|
|
1085
1073
|
btn.disabled = false;
|
|
1086
1074
|
}
|
|
@@ -1107,11 +1095,11 @@ function wireProjects() {
|
|
|
1107
1095
|
|
|
1108
1096
|
out.hidden = false;
|
|
1109
1097
|
out.innerHTML =
|
|
1110
|
-
'<p class="share-line"><b>' + (r.adopted ? "
|
|
1098
|
+
'<p class="share-line"><b>' + (r.adopted ? tr("publish.linked") : tr("publish.published")) +
|
|
1111
1099
|
'</b></p>' +
|
|
1112
|
-
'<p class="share-line">
|
|
1113
|
-
'<p class="share-warn">
|
|
1114
|
-
toast(r.adopted ? "
|
|
1100
|
+
'<p class="share-line">' + tr("publish.link") + ' <code>' + esc(r.url) + '</code></p>' +
|
|
1101
|
+
'<p class="share-warn">' + tr("publish.noPassword") + '</p>';
|
|
1102
|
+
toast(r.adopted ? tr("toast.linkedToAccount") : tr("publish.published"));
|
|
1115
1103
|
|
|
1116
1104
|
// Publicar pone a cero los "bloques nuevos desde que lo publicaste",
|
|
1117
1105
|
// pero ese número vive en `state`, que se quedaba con el valor de
|
|
@@ -1131,10 +1119,10 @@ function wireProjects() {
|
|
|
1131
1119
|
if (error.body?.needsLogin) {
|
|
1132
1120
|
out2.hidden = false;
|
|
1133
1121
|
out2.innerHTML =
|
|
1134
|
-
'<p class="share-line"><b>
|
|
1135
|
-
'<p class="share-line">
|
|
1136
|
-
'<code class="cmd">
|
|
1137
|
-
'<p class="share-warn">
|
|
1122
|
+
'<p class="share-line"><b>' + tr("publish.needsLogin.title") + '</b></p>' +
|
|
1123
|
+
'<p class="share-line">' + tr("publish.needsLogin.why") + '</p>' +
|
|
1124
|
+
'<code class="cmd">' + tr("publish.needsLogin.cmd") + '</code>' +
|
|
1125
|
+
'<p class="share-warn">' + tr("publish.needsLogin.once") + '</p>';
|
|
1138
1126
|
} else {
|
|
1139
1127
|
toast(error.message);
|
|
1140
1128
|
}
|
|
@@ -1147,7 +1135,7 @@ function wireProjects() {
|
|
|
1147
1135
|
e.preventDefault();
|
|
1148
1136
|
const projectId = form.closest(".pcard").dataset.project;
|
|
1149
1137
|
const value = Number(form.querySelector(".rate-new").value);
|
|
1150
|
-
if (!(value > 0)) { toast("
|
|
1138
|
+
if (!(value > 0)) { toast(tr("toast.enterRate")); return; }
|
|
1151
1139
|
try {
|
|
1152
1140
|
await api("/api/rate", {
|
|
1153
1141
|
method: "POST",
|
|
@@ -1158,7 +1146,7 @@ function wireProjects() {
|
|
|
1158
1146
|
}),
|
|
1159
1147
|
});
|
|
1160
1148
|
await loadProjects();
|
|
1161
|
-
toast("
|
|
1149
|
+
toast(tr("toast.rateSaved"));
|
|
1162
1150
|
} catch (error) { toast(error.message); }
|
|
1163
1151
|
});
|
|
1164
1152
|
});
|
|
@@ -1187,25 +1175,25 @@ function renderReports() {
|
|
|
1187
1175
|
const parts = [];
|
|
1188
1176
|
|
|
1189
1177
|
parts.push(`<div class="filters" id="rep-periods">
|
|
1190
|
-
<button type="button" class="period${state.period === "week" ? " is-on" : ""}" data-period="week"
|
|
1191
|
-
<button type="button" class="period${state.period === "month" ? " is-on" : ""}" data-period="month"
|
|
1192
|
-
<button type="button" class="period${state.period === "quarter" ? " is-on" : ""}" data-period="quarter"
|
|
1193
|
-
<button type="button" class="period${state.period === "all" ? " is-on" : ""}" data-period="all"
|
|
1178
|
+
<button type="button" class="period${state.period === "week" ? " is-on" : ""}" data-period="week">${tr("period.week")}</button>
|
|
1179
|
+
<button type="button" class="period${state.period === "month" ? " is-on" : ""}" data-period="month">${tr("period.month")}</button>
|
|
1180
|
+
<button type="button" class="period${state.period === "quarter" ? " is-on" : ""}" data-period="quarter">${tr("period.quarter")}</button>
|
|
1181
|
+
<button type="button" class="period${state.period === "all" ? " is-on" : ""}" data-period="all">${tr("period.all")}</button>
|
|
1194
1182
|
</div>`);
|
|
1195
1183
|
|
|
1196
1184
|
const fuera = overview.outsideRange;
|
|
1197
1185
|
if (fuera && fuera.blocks > 0) {
|
|
1198
|
-
parts.push(`<p class="note"
|
|
1199
|
-
fuera
|
|
1200
|
-
|
|
1186
|
+
parts.push(`<p class="note">${tr("reports.outside", {
|
|
1187
|
+
duration: fmtDuration(fuera.seconds), blocks: tr("blocks", { n: fuera.blocks }), all: tr("period.all"),
|
|
1188
|
+
})}</p>`);
|
|
1201
1189
|
}
|
|
1202
1190
|
|
|
1203
1191
|
if (overview.warning) parts.push(`<div class="warn">${esc(overview.warning)}</div>`);
|
|
1204
1192
|
|
|
1205
1193
|
if (overview.unbilled.length === 0) {
|
|
1206
1194
|
parts.push(`<div class="empty">
|
|
1207
|
-
<h2
|
|
1208
|
-
<p
|
|
1195
|
+
<h2>${tr("reports.empty.title")}</h2>
|
|
1196
|
+
<p>${tr("reports.empty.body")}</p>
|
|
1209
1197
|
<code>estela status</code>
|
|
1210
1198
|
</div>`);
|
|
1211
1199
|
} else {
|
|
@@ -1218,11 +1206,11 @@ function renderReports() {
|
|
|
1218
1206
|
|
|
1219
1207
|
if (overview.unbilled.length > REPORT_ROWS || q) {
|
|
1220
1208
|
parts.push(`<input type="search" class="search" id="rep-search"
|
|
1221
|
-
placeholder="
|
|
1209
|
+
placeholder="${tr("reports.search")}" value="${esc(state.reportsQuery)}">`);
|
|
1222
1210
|
}
|
|
1223
1211
|
|
|
1224
1212
|
if (q && matches.length === 0) {
|
|
1225
|
-
parts.push(`<p class="note"
|
|
1213
|
+
parts.push(`<p class="note">${tr("reports.noMatch", { q: esc(state.reportsQuery) })}</p>`);
|
|
1226
1214
|
}
|
|
1227
1215
|
|
|
1228
1216
|
const shown = state.reportsAll ? matches : matches.slice(0, REPORT_ROWS);
|
|
@@ -1235,8 +1223,9 @@ function renderReports() {
|
|
|
1235
1223
|
<button type="button" class="report-head" data-toggle-report="${esc(row.projectId)}" aria-expanded="${open}">
|
|
1236
1224
|
<div class="report-text">
|
|
1237
1225
|
<div class="report-name">${esc(row.projectName)}</div>
|
|
1238
|
-
<div class="report-client">${
|
|
1239
|
-
|
|
1226
|
+
<div class="report-client">${tr("reports.client", {
|
|
1227
|
+
client: esc(row.clientName), blocks: tr("blocks", { n: row.blocks }), date: esc(row.firstDay),
|
|
1228
|
+
})}</div>
|
|
1240
1229
|
</div>
|
|
1241
1230
|
<div class="report-figs">
|
|
1242
1231
|
<div class="report-hours">${fmtDuration(row.seconds)}</div>
|
|
@@ -1246,28 +1235,27 @@ function renderReports() {
|
|
|
1246
1235
|
</button>
|
|
1247
1236
|
${open ? `
|
|
1248
1237
|
<form class="report-actions" data-project="${esc(row.projectId)}">
|
|
1249
|
-
<label class="field"><span
|
|
1238
|
+
<label class="field"><span>${tr("reports.from")}</span>
|
|
1250
1239
|
<input type="date" class="rep-from" value="${esc(row.firstDay)}"></label>
|
|
1251
|
-
<label class="field"><span
|
|
1240
|
+
<label class="field"><span>${tr("reports.to")}</span>
|
|
1252
1241
|
<input type="date" class="rep-to" value="${localToday()}"></label>
|
|
1253
|
-
<label class="field"><span
|
|
1254
|
-
<input type="text" class="rep-author" placeholder="
|
|
1242
|
+
<label class="field"><span>${tr("reports.author")}</span>
|
|
1243
|
+
<input type="text" class="rep-author" placeholder="${tr("reports.authorPlaceholder")}"></label>
|
|
1255
1244
|
<label class="checkline"><input type="checkbox" class="rep-amounts">
|
|
1256
|
-
<span
|
|
1257
|
-
<button type="submit" class="btn-main"
|
|
1245
|
+
<span>${tr("reports.amounts")}</span></label>
|
|
1246
|
+
<button type="submit" class="btn-main">${tr("reports.download")}</button>
|
|
1258
1247
|
</form>` : ""}
|
|
1259
1248
|
</div>`);
|
|
1260
1249
|
}
|
|
1261
1250
|
|
|
1262
1251
|
if (!state.reportsAll && matches.length > REPORT_ROWS) {
|
|
1263
1252
|
parts.push(`<button type="button" class="more" id="reports-more">
|
|
1264
|
-
|
|
1253
|
+
${tr("reports.more", { n: matches.length - REPORT_ROWS })}</button>`);
|
|
1265
1254
|
} else if (state.reportsAll && matches.length > REPORT_ROWS) {
|
|
1266
|
-
parts.push(`<button type="button" class="more" id="reports-more"
|
|
1255
|
+
parts.push(`<button type="button" class="more" id="reports-more">${tr("common.showLess")}</button>`);
|
|
1267
1256
|
}
|
|
1268
1257
|
|
|
1269
|
-
parts.push(`<p class="note"
|
|
1270
|
-
Estela no emite documentos fiscales, adjunta este respaldo a la tuya.</p>`);
|
|
1258
|
+
parts.push(`<p class="note">${tr("reports.note")}</p>`);
|
|
1271
1259
|
}
|
|
1272
1260
|
|
|
1273
1261
|
$("#reports-body").innerHTML = parts.join("");
|
|
@@ -1317,7 +1305,7 @@ function renderReports() {
|
|
|
1317
1305
|
// El servidor lo devuelve con Content-Disposition, así que el navegador
|
|
1318
1306
|
// lo descarga en vez de abrirlo.
|
|
1319
1307
|
window.location.href = `/api/report?${q}`;
|
|
1320
|
-
toast("
|
|
1308
|
+
toast(tr("toast.reportDownloaded"));
|
|
1321
1309
|
});
|
|
1322
1310
|
});
|
|
1323
1311
|
}
|
|
@@ -1346,19 +1334,18 @@ async function wireSync() {
|
|
|
1346
1334
|
btn.addEventListener("click", async () => {
|
|
1347
1335
|
btn.disabled = true;
|
|
1348
1336
|
const antes = btn.textContent;
|
|
1349
|
-
btn.textContent = "
|
|
1337
|
+
btn.textContent = tr("sync.syncing");
|
|
1350
1338
|
try {
|
|
1351
1339
|
const r = await api("/api/sync", { method: "POST" });
|
|
1352
1340
|
const bien = r.projects.filter((p) => p.ok).length;
|
|
1353
1341
|
const mal = r.projects.filter((p) => !p.ok);
|
|
1354
1342
|
toast(mal.length
|
|
1355
|
-
?
|
|
1356
|
-
: (bien ?
|
|
1357
|
-
: "No hay proyectos con sync activado"));
|
|
1343
|
+
? tr("sync.partial", { ok: bien, project: mal[0].projectId, detail: mal[0].detalle })
|
|
1344
|
+
: (bien ? tr("sync.done", { n: bien }) : tr("sync.none")));
|
|
1358
1345
|
if (state.view === "projects") await loadProjects();
|
|
1359
1346
|
} catch (error) {
|
|
1360
1347
|
toast(error.body?.needsLogin
|
|
1361
|
-
? "
|
|
1348
|
+
? tr("needsLogin.cmd")
|
|
1362
1349
|
: error.message);
|
|
1363
1350
|
}
|
|
1364
1351
|
btn.textContent = antes;
|
|
@@ -1419,7 +1406,7 @@ $("#approve-btn").addEventListener("click", async () => {
|
|
|
1419
1406
|
body: JSON.stringify({ date: state.date }),
|
|
1420
1407
|
});
|
|
1421
1408
|
await loadDay();
|
|
1422
|
-
toast(
|
|
1409
|
+
toast(tr("toast.blocksApproved", { n: approved }));
|
|
1423
1410
|
} catch (error) {
|
|
1424
1411
|
toast(error.message);
|
|
1425
1412
|
}
|
|
@@ -1452,7 +1439,7 @@ function reloadView() {
|
|
|
1452
1439
|
function showSync(status) {
|
|
1453
1440
|
const warn = $("#sync-warn");
|
|
1454
1441
|
if (status && status.ok === false) {
|
|
1455
|
-
warn.textContent = "
|
|
1442
|
+
warn.textContent = tr("refresh.failed");
|
|
1456
1443
|
warn.title = status.error || "";
|
|
1457
1444
|
warn.hidden = false;
|
|
1458
1445
|
} else {
|
|
@@ -1468,7 +1455,7 @@ async function sync({ force }) {
|
|
|
1468
1455
|
const btn = $("#sync");
|
|
1469
1456
|
const label = $("#sync-label");
|
|
1470
1457
|
btn.classList.add("is-busy");
|
|
1471
|
-
label.textContent = "
|
|
1458
|
+
label.textContent = tr("refresh.searching");
|
|
1472
1459
|
try {
|
|
1473
1460
|
// force = el botón: pide un import nuevo. Sin force solo se consulta cómo
|
|
1474
1461
|
// fue el último, que es lo que basta al volver a la pestaña.
|
|
@@ -1481,7 +1468,7 @@ async function sync({ force }) {
|
|
|
1481
1468
|
showSync({ ok: false, error: String(error) });
|
|
1482
1469
|
} finally {
|
|
1483
1470
|
btn.classList.remove("is-busy");
|
|
1484
|
-
label.textContent = "
|
|
1471
|
+
label.textContent = tr("refresh.label");
|
|
1485
1472
|
syncing = false;
|
|
1486
1473
|
}
|
|
1487
1474
|
}
|
|
@@ -1563,7 +1550,7 @@ document.querySelectorAll(".upgrade-option").forEach((btn) => {
|
|
|
1563
1550
|
// criterio que ya usa /api/publish para distinguir "hace falta
|
|
1564
1551
|
// vincular cuenta" de un error genérico.
|
|
1565
1552
|
toast(error.body?.needsLogin
|
|
1566
|
-
? "
|
|
1553
|
+
? tr("upgrade.needsLogin")
|
|
1567
1554
|
: error.message);
|
|
1568
1555
|
} finally {
|
|
1569
1556
|
btn.disabled = false;
|
|
@@ -1593,6 +1580,9 @@ $("#banner-close").addEventListener("click", () => {
|
|
|
1593
1580
|
$("#banner-promo").hidden = true;
|
|
1594
1581
|
});
|
|
1595
1582
|
|
|
1583
|
+
// Cambiar de idioma recarga: todo lo ya pintado sale de nuevo en el otro.
|
|
1584
|
+
$("#lang-switch").addEventListener("click", () => switchLang());
|
|
1585
|
+
|
|
1596
1586
|
// ── Arranque ───────────────────────────────────────────────────────────
|
|
1597
1587
|
|
|
1598
1588
|
/* Arranca en el resumen: la primera pregunta al abrir es "¿cómo va todo?",
|