estela 0.2.4 → 0.3.2
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 +55 -0
- package/dist/billing/assign.d.ts +11 -1
- package/dist/billing/assign.js +14 -0
- package/dist/billing/sessionize.d.ts +5 -0
- package/dist/billing/sessionize.js +11 -1
- package/dist/cli.js +127 -4
- package/dist/db/schema.js +10 -0
- package/dist/db/store.d.ts +9 -0
- package/dist/db/store.js +24 -0
- package/dist/export/invoice-pdf.d.ts +2 -0
- package/dist/export/invoice-pdf.js +26 -16
- package/dist/i18n/en.js +45 -0
- package/dist/metrics/plan.d.ts +110 -0
- package/dist/metrics/plan.js +175 -0
- package/dist/plan-blocks.d.ts +26 -0
- package/dist/plan-blocks.js +110 -0
- package/dist/plan-prices.d.ts +27 -0
- package/dist/plan-prices.js +29 -0
- package/dist/plan-report.d.ts +96 -0
- package/dist/plan-report.js +190 -0
- package/dist/server.js +13 -7
- package/package.json +1 -1
- package/web/app.css +1 -0
- package/web/app.js +10 -5
- package/web/i18n.js +6 -4
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderPlanReport = renderPlanReport;
|
|
4
|
+
exports.recommendationText = recommendationText;
|
|
5
|
+
exports.pricesStale = pricesStale;
|
|
6
|
+
exports.renderNoPlan = renderNoPlan;
|
|
7
|
+
exports.renderNoData = renderNoData;
|
|
8
|
+
exports.planToJson = planToJson;
|
|
9
|
+
const index_js_1 = require("./i18n/index.js");
|
|
10
|
+
const plan_prices_js_1 = require("./plan-prices.js");
|
|
11
|
+
const statusline_js_1 = require("./statusline.js");
|
|
12
|
+
const ESC = "\x1b[";
|
|
13
|
+
const JADE = `${ESC}38;2;70;192;167m`; // #46C0A7, como en la status line
|
|
14
|
+
const AMBER = `${ESC}38;2;221;168;90m`;
|
|
15
|
+
const DIM = `${ESC}90m`;
|
|
16
|
+
const RESET = `${ESC}0m`;
|
|
17
|
+
const LABEL = 21;
|
|
18
|
+
function renderPlanReport(a, o) {
|
|
19
|
+
const paint = (code, text) => (o.color ? `${code}${text}${RESET}` : text);
|
|
20
|
+
const row = (label, value) => `${label.padEnd(LABEL)}${value}`;
|
|
21
|
+
const out = [];
|
|
22
|
+
out.push(header(a, paint));
|
|
23
|
+
out.push("");
|
|
24
|
+
const f = a.fiveHour;
|
|
25
|
+
out.push(row((0, index_js_1.tr) `Ventanas de 5 h`, f.windows.length === 0
|
|
26
|
+
? (0, index_js_1.tr) `ninguna completa todavía`
|
|
27
|
+
: (0, index_js_1.tr) `${f.windows.length} ventanas · pico mediano ${pct(f.medianPeak)} % · p90 ${pct(f.p90Peak)} % · máximo ${pct(f.maxPeak)} %`));
|
|
28
|
+
out.push(row((0, index_js_1.tr) `Topes (≥95 %)`, String(f.caps)));
|
|
29
|
+
if (f.stoppedMs > 0) {
|
|
30
|
+
out.push(row((0, index_js_1.tr) `Parado tras topes`, (0, index_js_1.tr) `hasta ${(0, statusline_js_1.compactDuration)(f.stoppedMs / 1000)} (estimado: no sabemos si seguías trabajando)`));
|
|
31
|
+
}
|
|
32
|
+
const w = a.week;
|
|
33
|
+
out.push(row((0, index_js_1.tr) `Semana`, w.weeks.length === 0
|
|
34
|
+
? (0, index_js_1.tr) `sin datos de la semana`
|
|
35
|
+
: (0, index_js_1.tr) `pico mediano ${pct(w.medianPeak)} % · máximo ${pct(w.maxPeak)} % · ${w.weeksAtOrAbove90} semanas ≥ 90 %`));
|
|
36
|
+
if (o.blocks) {
|
|
37
|
+
out.push(row((0, index_js_1.tr) `Bloqueos (429)`, (0, index_js_1.tr) `${o.blocks.fiveHour} de 5 h · ${o.blocks.sevenDay} semanales, según los transcripts de Claude Code`));
|
|
38
|
+
}
|
|
39
|
+
if (f.windows.length > 0) {
|
|
40
|
+
out.push("");
|
|
41
|
+
out.push((0, index_js_1.tr) `Si estuvieras en…`);
|
|
42
|
+
for (const alt of [...a.alternatives].sort((x, y) => x.plan.monthlyUsd - y.plan.monthlyUsd)) {
|
|
43
|
+
out.push(` ${`${alt.plan.name} (${usd(alt.plan.monthlyUsd)})`.padEnd(LABEL - 2)}${alternativeText(alt, a)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
out.push("");
|
|
47
|
+
if (a.dataState === "insufficient") {
|
|
48
|
+
out.push(paint(AMBER, (0, index_js_1.tr) `Datos de ${a.daysWithData} días. La recomendación necesita al menos 14.`));
|
|
49
|
+
}
|
|
50
|
+
else if (a.recommendation) {
|
|
51
|
+
const label = a.dataState === "provisional"
|
|
52
|
+
? (0, index_js_1.tr) `Recomendación (provisional, ${a.daysWithData} días)`
|
|
53
|
+
: (0, index_js_1.tr) `Recomendación`;
|
|
54
|
+
const text = recommendationText(a);
|
|
55
|
+
out.push(row(label, paint(a.recommendation.kind === "tight" ? AMBER : JADE, text)));
|
|
56
|
+
const diff = priceDiffText(a);
|
|
57
|
+
if (diff)
|
|
58
|
+
out.push(row("", paint(DIM, diff)));
|
|
59
|
+
}
|
|
60
|
+
out.push("");
|
|
61
|
+
out.push((0, index_js_1.tr) `Límite semanal entre planes: Anthropic no publica la relación. No lo estimamos.`);
|
|
62
|
+
out.push((0, index_js_1.tr) `Basado en el multiplicador por sesión publicado por Anthropic:`);
|
|
63
|
+
out.push(plan_prices_js_1.MULTIPLIER_SOURCE);
|
|
64
|
+
out.push((0, index_js_1.tr) `Anthropic puede cambiar los límites; repite el análisis cada mes.`);
|
|
65
|
+
out.push((0, index_js_1.tr) `Precios según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo: ${plan_prices_js_1.PRICES_SOURCE}`);
|
|
66
|
+
if (pricesStale(o.now))
|
|
67
|
+
out.push(paint(AMBER, (0, index_js_1.tr) `Esa fecha tiene más de ${plan_prices_js_1.PRICES_STALE_DAYS} días: los precios pueden estar desactualizados.`));
|
|
68
|
+
return out.join("\n");
|
|
69
|
+
}
|
|
70
|
+
function header(a, paint) {
|
|
71
|
+
return `${paint(JADE, "estela")} plan · ${a.plan.name} ${(0, index_js_1.tr) `(desde ${day(a.since)})`} · ${(0, index_js_1.tr) `${a.daysWithData} días con datos`}`;
|
|
72
|
+
}
|
|
73
|
+
function alternativeText(alt, a) {
|
|
74
|
+
const n = a.fiveHour.windows.length;
|
|
75
|
+
if (alt.direction === "higher") {
|
|
76
|
+
if (a.fiveHour.caps === 0)
|
|
77
|
+
return (0, index_js_1.tr) `sin topes, igual que ahora`;
|
|
78
|
+
return (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · habrías evitado ${alt.capsAvoided} de ${a.fiveHour.caps} topes (con el uso que llegó a registrarse)`;
|
|
79
|
+
}
|
|
80
|
+
return alt.blockedWindows === 0
|
|
81
|
+
? (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · no te habría bloqueado en ninguna de las ${n} ventanas`
|
|
82
|
+
: (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · te habría bloqueado en ${alt.blockedWindows} de ${n} ventanas`;
|
|
83
|
+
}
|
|
84
|
+
function recommendationText(a) {
|
|
85
|
+
const r = a.recommendation;
|
|
86
|
+
if (!r)
|
|
87
|
+
return "";
|
|
88
|
+
switch (r.kind) {
|
|
89
|
+
case "tight":
|
|
90
|
+
return r.higher && r.capsAvoided !== null
|
|
91
|
+
? (0, index_js_1.tr) `Vas justo con tu plan. Con ${r.higher.name} habrías evitado ${r.capsAvoided} de ${a.fiveHour.caps} topes.`
|
|
92
|
+
: (0, index_js_1.tr) `Vas justo con tu plan.`;
|
|
93
|
+
case "enough":
|
|
94
|
+
return (0, index_js_1.tr) `Con los datos de estas ${r.weeks} semanas, el plan ${r.plan.name} te habría bastado en todas las ventanas de 5 h.`;
|
|
95
|
+
case "almost":
|
|
96
|
+
return (0, index_js_1.tr) `El plan ${r.plan.name} te habría bastado casi siempre: te habría bloqueado ${r.blocked} veces en estas semanas.`;
|
|
97
|
+
case "fits":
|
|
98
|
+
return (0, index_js_1.tr) `Tu plan actual encaja con tu uso.`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** La diferencia de precio de lo que se recomienda, si es cambiar de plan. */
|
|
102
|
+
function priceDiffText(a) {
|
|
103
|
+
const r = a.recommendation;
|
|
104
|
+
const other = r?.kind === "enough" || r?.kind === "almost" ? r.plan
|
|
105
|
+
: r?.kind === "tight" ? r.higher : null;
|
|
106
|
+
if (!other)
|
|
107
|
+
return null;
|
|
108
|
+
const diff = other.monthlyUsd - a.plan.monthlyUsd;
|
|
109
|
+
return diff < 0
|
|
110
|
+
? (0, index_js_1.tr) `${usd(-diff)} menos al mes, según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo.`
|
|
111
|
+
: (0, index_js_1.tr) `${usd(diff)} más al mes, según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo.`;
|
|
112
|
+
}
|
|
113
|
+
function pricesStale(now) {
|
|
114
|
+
return now.getTime() - Date.parse(`${plan_prices_js_1.PRICES_CHECKED_AT}T00:00:00Z`) > plan_prices_js_1.PRICES_STALE_DAYS * 86_400_000;
|
|
115
|
+
}
|
|
116
|
+
/** Sin plan declarado: cómo declararlo. */
|
|
117
|
+
function renderNoPlan() {
|
|
118
|
+
return [
|
|
119
|
+
(0, index_js_1.tr) `estela plan compara tu uso real de Claude con los otros planes. Primero dime cuál pagas:`,
|
|
120
|
+
"",
|
|
121
|
+
" estela plan set pro",
|
|
122
|
+
" estela plan set max5",
|
|
123
|
+
" estela plan set max20",
|
|
124
|
+
"",
|
|
125
|
+
(0, index_js_1.tr) `Si ya lo tenías antes de hoy, añade desde cuándo: --since AAAA-MM-DD`,
|
|
126
|
+
].join("\n");
|
|
127
|
+
}
|
|
128
|
+
function renderNoData(a, apiKey, color) {
|
|
129
|
+
const paint = (code, text) => (color ? `${code}${text}${RESET}` : text);
|
|
130
|
+
return [
|
|
131
|
+
header(a, paint), "",
|
|
132
|
+
apiKey
|
|
133
|
+
? (0, index_js_1.tr) `Este análisis solo funciona con planes Pro o Max.`
|
|
134
|
+
: (0, index_js_1.tr) `Aún no hay datos. Instala la status line (estela statusline install) y vuelve en unos días.`,
|
|
135
|
+
...(apiKey ? [] : [(0, index_js_1.tr) `Solo se recogen en Claude Code en la terminal: la extensión de VS Code no ejecuta la status line.`]),
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
/**
|
|
140
|
+
* `estela plan --json`. Estructura estable (documentada en el README):
|
|
141
|
+
* campos nuevos se pueden añadir, ninguno cambia de nombre ni de significado.
|
|
142
|
+
*/
|
|
143
|
+
function planToJson(a, o) {
|
|
144
|
+
const r1 = (n) => (n === null ? null : Math.round(n * 10) / 10);
|
|
145
|
+
const rec = a.recommendation;
|
|
146
|
+
return {
|
|
147
|
+
version: 1,
|
|
148
|
+
plan: { id: a.plan.id, name: a.plan.name, since: a.since.toISOString(), sessionMultiplier: a.plan.sessionMultiplier },
|
|
149
|
+
period: { from: a.from.toISOString(), to: a.to.toISOString(), days: a.days, daysWithData: a.daysWithData, samples: a.samples },
|
|
150
|
+
dataState: a.dataState,
|
|
151
|
+
fiveHour: {
|
|
152
|
+
windows: a.fiveHour.windows.length,
|
|
153
|
+
medianPeak: r1(a.fiveHour.medianPeak), p90Peak: r1(a.fiveHour.p90Peak), maxPeak: r1(a.fiveHour.maxPeak),
|
|
154
|
+
caps: a.fiveHour.caps,
|
|
155
|
+
stoppedMinutesUpperBound: Math.round(a.fiveHour.stoppedMs / 60_000),
|
|
156
|
+
},
|
|
157
|
+
week: {
|
|
158
|
+
weeks: a.week.weeks.length,
|
|
159
|
+
medianPeak: r1(a.week.medianPeak), maxPeak: r1(a.week.maxPeak),
|
|
160
|
+
weeksAtOrAbove90: a.week.weeksAtOrAbove90,
|
|
161
|
+
},
|
|
162
|
+
blocks429: o.blocks,
|
|
163
|
+
alternatives: [...a.alternatives].sort((x, y) => x.plan.monthlyUsd - y.plan.monthlyUsd).map((x) => ({
|
|
164
|
+
plan: x.plan.id, name: x.plan.name, direction: x.direction,
|
|
165
|
+
monthlyUsd: x.plan.monthlyUsd, monthlyDiffUsd: x.plan.monthlyUsd - a.plan.monthlyUsd,
|
|
166
|
+
p90Equivalent: r1(x.p90Equivalent), maxEquivalent: r1(x.maxEquivalent),
|
|
167
|
+
blockedWindows: x.blockedWindows, capsAvoided: x.capsAvoided,
|
|
168
|
+
})),
|
|
169
|
+
recommendation: rec === null ? null : {
|
|
170
|
+
kind: rec.kind,
|
|
171
|
+
provisional: a.dataState === "provisional",
|
|
172
|
+
plan: rec.kind === "enough" || rec.kind === "almost" ? rec.plan.id : rec.kind === "tight" ? rec.higher?.id ?? null : null,
|
|
173
|
+
blockedWindows: rec.kind === "almost" ? rec.blocked : null,
|
|
174
|
+
capsAvoided: rec.kind === "tight" ? rec.capsAvoided : null,
|
|
175
|
+
text: recommendationText(a),
|
|
176
|
+
},
|
|
177
|
+
weeklyBetweenPlans: "not_published",
|
|
178
|
+
sources: {
|
|
179
|
+
sessionMultiplier: plan_prices_js_1.MULTIPLIER_SOURCE,
|
|
180
|
+
prices: plan_prices_js_1.PRICES_SOURCE, pricesCheckedAt: plan_prices_js_1.PRICES_CHECKED_AT, pricesStale: pricesStale(o.now),
|
|
181
|
+
},
|
|
182
|
+
plans: plan_prices_js_1.CLAUDE_PLANS.map((p) => ({ id: p.id, name: p.name, sessionMultiplier: p.sessionMultiplier, monthlyUsd: p.monthlyUsd })),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function pct(n) { return Math.round(n); }
|
|
186
|
+
function usd(n) { return (0, index_js_1.tr) `${n} $`; }
|
|
187
|
+
function day(d) {
|
|
188
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=plan-report.js.map
|
package/dist/server.js
CHANGED
|
@@ -142,7 +142,7 @@ async function importOnce(dbPath) {
|
|
|
142
142
|
// Solo tus commits producen horas tuyas.
|
|
143
143
|
const commits = (0, identity_js_1.onlyMine)(allRepoCommits, mine);
|
|
144
144
|
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(turns), commits);
|
|
145
|
-
const fromCommits = (0,
|
|
145
|
+
const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commits, fromAgents);
|
|
146
146
|
// Las mismas reglas que `estela import` (billing/assign.ts). Las horas de
|
|
147
147
|
// subcarpetas y worktrees solo se recuperan si ya se confirmó una vez
|
|
148
148
|
// desde la terminal: el panel no cambia horas importadas en segundo plano
|
|
@@ -581,11 +581,11 @@ async function api(req, res, url, dbPath) {
|
|
|
581
581
|
// recalcular nada: el mismo papel que salió el día que se emitió.
|
|
582
582
|
const reimpresion = /^\/api\/invoices\/([A-Za-z0-9-]+)\/pdf$/.exec(path);
|
|
583
583
|
if (reimpresion && req.method === "GET") {
|
|
584
|
-
return sendStoredInvoicePdf(db, res, reimpresion[1], url.searchParams.get("author") ?? "");
|
|
584
|
+
return sendStoredInvoicePdf(db, res, reimpresion[1], url.searchParams.get("author") ?? "", url.searchParams.get("amounts") !== "0");
|
|
585
585
|
}
|
|
586
586
|
if (path === "/api/invoice" && req.method === "POST") {
|
|
587
587
|
const body = await readJson(req);
|
|
588
|
-
return sendInvoice(db, res, String(body["projectId"] ?? ""), String(body["cutoff"] ?? ""), typeof body["author"] === "string" ? body["author"] : "");
|
|
588
|
+
return sendInvoice(db, res, String(body["projectId"] ?? ""), String(body["cutoff"] ?? ""), typeof body["author"] === "string" ? body["author"] : "", body["withAmounts"] !== false);
|
|
589
589
|
}
|
|
590
590
|
if (path === "/api/entry" && req.method === "POST") {
|
|
591
591
|
const body = await readJson(req);
|
|
@@ -1368,7 +1368,7 @@ function sendReport(db, res, projectId, from, to, withAmounts, author) {
|
|
|
1368
1368
|
* congeló al emitirla. Es lo que hace que el papel de agosto siga siendo el
|
|
1369
1369
|
* papel de agosto cuando se pide en diciembre.
|
|
1370
1370
|
*/
|
|
1371
|
-
function sendStoredInvoicePdf(db, res, number, author) {
|
|
1371
|
+
function sendStoredInvoicePdf(db, res, number, author, withAmounts) {
|
|
1372
1372
|
const invoice = store.getInvoice(db, number);
|
|
1373
1373
|
if (!invoice)
|
|
1374
1374
|
return json(res, 404, { error: "Esa factura no existe." });
|
|
@@ -1382,15 +1382,20 @@ function sendStoredInvoicePdf(db, res, number, author) {
|
|
|
1382
1382
|
const pdf = (0, index_js_2.withLang)((0, index_js_2.documentLang)({ clientLanguage: client.language }), () => (0, invoice_pdf_js_1.invoiceToPdf)(invoice, client, project, {
|
|
1383
1383
|
...(author ? { issuer: { name: author } } : {}),
|
|
1384
1384
|
...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
|
|
1385
|
+
withAmounts,
|
|
1385
1386
|
}));
|
|
1386
1387
|
res.writeHead(200, {
|
|
1387
1388
|
"Content-Type": "application/pdf",
|
|
1388
|
-
"Content-Disposition": `attachment; filename="${invoice.number}
|
|
1389
|
+
"Content-Disposition": `attachment; filename="${invoicePdfName(invoice.number, withAmounts)}"`,
|
|
1389
1390
|
"Content-Length": pdf.length,
|
|
1390
1391
|
});
|
|
1391
1392
|
res.end(pdf);
|
|
1392
1393
|
}
|
|
1393
|
-
|
|
1394
|
+
/** Con otro nombre, para no confundir al guardarlos juntos cuál lleva importes. */
|
|
1395
|
+
function invoicePdfName(number, withAmounts) {
|
|
1396
|
+
return withAmounts ? `${number}.pdf` : `${number}-sin-importes.pdf`;
|
|
1397
|
+
}
|
|
1398
|
+
function sendInvoice(db, res, projectId, cutoffRaw, author, withAmounts) {
|
|
1394
1399
|
const project = store.getProject(db, projectId);
|
|
1395
1400
|
if (!project)
|
|
1396
1401
|
return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
|
|
@@ -1429,8 +1434,9 @@ function sendInvoice(db, res, projectId, cutoffRaw, author) {
|
|
|
1429
1434
|
const pdf = (0, index_js_2.withLang)((0, index_js_2.documentLang)({ clientLanguage: client.language }), () => (0, invoice_pdf_js_1.invoiceToPdf)(invoice, client, project, {
|
|
1430
1435
|
...(author ? { issuer: { name: author } } : {}),
|
|
1431
1436
|
...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
|
|
1437
|
+
withAmounts,
|
|
1432
1438
|
}));
|
|
1433
|
-
const filename =
|
|
1439
|
+
const filename = invoicePdfName(invoice.number, withAmounts);
|
|
1434
1440
|
res.writeHead(200, {
|
|
1435
1441
|
"Content-Type": "application/pdf",
|
|
1436
1442
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "estela",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Time tracking you never fill in: rebuilds your billable hours from Claude Code, Codex and GitHub Copilot sessions and your Git history, and splits what the AI cost per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"time-tracking",
|
package/web/app.css
CHANGED
|
@@ -900,6 +900,7 @@ main.wide { max-width: 1180px; }
|
|
|
900
900
|
subraye y que se centre como el resto. */
|
|
901
901
|
.invoice-dl{flex:0 0 auto;text-decoration:none;display:inline-flex;
|
|
902
902
|
align-items:center;padding:9px 18px;font-size:.9rem}
|
|
903
|
+
.invoice-dls{flex:0 0 auto;display:flex;gap:8px;flex-wrap:wrap}
|
|
903
904
|
@media(max-width:620px){
|
|
904
905
|
.invoice-row{flex-wrap:wrap}
|
|
905
906
|
.invoice-main{flex:1 1 100%}
|
package/web/app.js
CHANGED
|
@@ -1469,8 +1469,12 @@ function renderReports() {
|
|
|
1469
1469
|
<div class="report-hours">${fmtDuration(f.totalSeconds)}</div>
|
|
1470
1470
|
<div class="report-amount">${importe}</div>
|
|
1471
1471
|
</div>
|
|
1472
|
-
<
|
|
1473
|
-
|
|
1472
|
+
<div class="invoice-dls">
|
|
1473
|
+
<a class="btn-main invoice-dl" download
|
|
1474
|
+
href="/api/invoices/${encodeURIComponent(f.number)}/pdf">${tr("invoices.withAmounts")}</a>
|
|
1475
|
+
<a class="chip invoice-dl" download
|
|
1476
|
+
href="/api/invoices/${encodeURIComponent(f.number)}/pdf?amounts=0">${tr("invoices.withoutAmounts")}</a>
|
|
1477
|
+
</div>
|
|
1474
1478
|
</div>`;
|
|
1475
1479
|
}).join("") + "</div>");
|
|
1476
1480
|
}
|
|
@@ -1535,6 +1539,7 @@ function renderReports() {
|
|
|
1535
1539
|
const projectId = form.dataset.project;
|
|
1536
1540
|
const cutoff = form.querySelector(".rep-to").value;
|
|
1537
1541
|
const author = form.querySelector(".rep-author").value;
|
|
1542
|
+
const withAmounts = form.querySelector(".rep-amounts").checked;
|
|
1538
1543
|
const row = (state.reportsOverview?.unbilled || []).find((r) => r.projectId === projectId);
|
|
1539
1544
|
const proyecto = row ? row.projectName : projectId;
|
|
1540
1545
|
|
|
@@ -1542,7 +1547,7 @@ function renderReports() {
|
|
|
1542
1547
|
|
|
1543
1548
|
btn.disabled = true;
|
|
1544
1549
|
try {
|
|
1545
|
-
await cutoffAndInvoice(projectId, cutoff, author);
|
|
1550
|
+
await cutoffAndInvoice(projectId, cutoff, author, withAmounts);
|
|
1546
1551
|
toast(tr("toast.invoiced"));
|
|
1547
1552
|
await loadReports();
|
|
1548
1553
|
} catch (error) {
|
|
@@ -1561,11 +1566,11 @@ function renderReports() {
|
|
|
1561
1566
|
* con un enlace sintético — el mismo patrón que usa cualquier app sin
|
|
1562
1567
|
* framework para esto.
|
|
1563
1568
|
*/
|
|
1564
|
-
async function cutoffAndInvoice(projectId, cutoff, author) {
|
|
1569
|
+
async function cutoffAndInvoice(projectId, cutoff, author, withAmounts) {
|
|
1565
1570
|
const res = await fetch("/api/invoice", {
|
|
1566
1571
|
method: "POST",
|
|
1567
1572
|
headers: { "Content-Type": "application/json" },
|
|
1568
|
-
body: JSON.stringify({ projectId, cutoff, author }),
|
|
1573
|
+
body: JSON.stringify({ projectId, cutoff, author, withAmounts }),
|
|
1569
1574
|
});
|
|
1570
1575
|
if (!res.ok) {
|
|
1571
1576
|
let message = `Error ${res.status}`;
|
package/web/i18n.js
CHANGED
|
@@ -308,9 +308,10 @@
|
|
|
308
308
|
"reports.allbilled.title": "Todo facturado",
|
|
309
309
|
"reports.allbilled.body": "No queda nada pendiente en este periodo. Lo ya emitido está abajo, y se puede volver a descargar.",
|
|
310
310
|
"invoices.title": "Ya facturado",
|
|
311
|
-
"invoices.note": "Se reimprime tal y como se emiti
|
|
311
|
+
"invoices.note": "Se reimprime tal y como se emitió, con o sin importes: las cifras quedaron congeladas ese día.",
|
|
312
312
|
"invoices.upto": "Hasta el {date}",
|
|
313
|
-
"invoices.
|
|
313
|
+
"invoices.withAmounts": "Con importes",
|
|
314
|
+
"invoices.withoutAmounts": "Sin importes",
|
|
314
315
|
"reports.noMatch": "Ningún proyecto coincide con «{q}».",
|
|
315
316
|
"reports.client": "{client} · {blocks} · todo lo pendiente desde {date}",
|
|
316
317
|
"reports.from": "Desde",
|
|
@@ -613,9 +614,10 @@
|
|
|
613
614
|
"reports.allbilled.title": "All invoiced",
|
|
614
615
|
"reports.allbilled.body": "Nothing pending in this period. What you already issued is below, and can be downloaded again.",
|
|
615
616
|
"invoices.title": "Already invoiced",
|
|
616
|
-
"invoices.note": "Reprinted exactly as issued
|
|
617
|
+
"invoices.note": "Reprinted exactly as issued, with or without amounts: the figures were frozen that day.",
|
|
617
618
|
"invoices.upto": "Up to {date}",
|
|
618
|
-
"invoices.
|
|
619
|
+
"invoices.withAmounts": "With amounts",
|
|
620
|
+
"invoices.withoutAmounts": "Without amounts",
|
|
619
621
|
"reports.noMatch": "No project matches “{q}”.",
|
|
620
622
|
"reports.client": "{client} · {blocks} · everything pending since {date}",
|
|
621
623
|
"reports.from": "From",
|