estela 0.3.0 → 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/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 +8 -3
- package/dist/db/store.js +6 -0
- package/dist/export/invoice-pdf.d.ts +2 -0
- package/dist/export/invoice-pdf.js +26 -16
- 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
package/dist/billing/assign.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import type { TimeEntry } from "@estela/shared";
|
|
2
|
+
import type { CommitRecord, TimeEntry } from "@estela/shared";
|
|
3
3
|
import { type WorkBlock } from "./sessionize.js";
|
|
4
4
|
/**
|
|
5
5
|
* De bloques de trabajo a imputaciones: a qué proyecto va cada uno y con qué
|
|
@@ -39,6 +39,16 @@ export interface AssignmentPlan {
|
|
|
39
39
|
readonly blocked: readonly RecoveryBlocked[];
|
|
40
40
|
}
|
|
41
41
|
export declare function entryId(projectId: string, day: string, branch: string | null): string;
|
|
42
|
+
/**
|
|
43
|
+
* Los bloques deducidos solo de commits: los que no se solapan con ninguna
|
|
44
|
+
* sesión de agente, tampoco con las que ya no están en el disco.
|
|
45
|
+
*
|
|
46
|
+
* Claude Code limpia sus transcripts antiguos, así que lo escaneado no es todo
|
|
47
|
+
* lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
|
|
48
|
+
* trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
|
|
49
|
+
* guarda todos los turnos: `saveTurns` no borra nada.
|
|
50
|
+
*/
|
|
51
|
+
export declare function commitOnlyBlocks(db: DatabaseSync, commits: readonly CommitRecord[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
|
|
42
52
|
export declare function planEntries(db: DatabaseSync, raw: readonly WorkBlock[], options: {
|
|
43
53
|
readonly recover: boolean;
|
|
44
54
|
}): AssignmentPlan;
|
package/dist/billing/assign.js
CHANGED
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.entryId = entryId;
|
|
37
|
+
exports.commitOnlyBlocks = commitOnlyBlocks;
|
|
37
38
|
exports.planEntries = planEntries;
|
|
38
39
|
const shared_1 = require("@estela/shared");
|
|
39
40
|
const store = __importStar(require("../db/store.js"));
|
|
@@ -45,6 +46,19 @@ function entryId(projectId, day, branch) {
|
|
|
45
46
|
const slug = (branch ?? "sin-rama").replace(/[^a-zA-Z0-9]+/g, "-");
|
|
46
47
|
return `te_${projectId}_${day}_${slug}`;
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Los bloques deducidos solo de commits: los que no se solapan con ninguna
|
|
51
|
+
* sesión de agente, tampoco con las que ya no están en el disco.
|
|
52
|
+
*
|
|
53
|
+
* Claude Code limpia sus transcripts antiguos, así que lo escaneado no es todo
|
|
54
|
+
* lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
|
|
55
|
+
* trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
|
|
56
|
+
* guarda todos los turnos: `saveTurns` no borra nada.
|
|
57
|
+
*/
|
|
58
|
+
function commitOnlyBlocks(db, commits, agentBlocks) {
|
|
59
|
+
const saved = (0, sessionize_js_1.sessionize)(store.turnsBetween(db, new Date(0), new Date("9999-12-31")));
|
|
60
|
+
return (0, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commits), [...agentBlocks, ...saved]);
|
|
61
|
+
}
|
|
48
62
|
function planEntries(db, raw, options) {
|
|
49
63
|
const ownerOf = (0, project_of_js_1.ownerResolver)(db);
|
|
50
64
|
const direct = (repo) => (repo ? store.projectForRepo(db, repo) : null);
|
|
@@ -113,6 +113,11 @@ export declare function sessionizeCommits(commits: readonly CommitRecord[], opti
|
|
|
113
113
|
*
|
|
114
114
|
* Sin esto se contaría dos veces el mismo rato: el agente ya cubrió esas horas
|
|
115
115
|
* y el commit que las cerró generaría otro bloque encima.
|
|
116
|
+
*
|
|
117
|
+
* Mismo repositorio quiere decir lo mismo que en `attachCommits`: la carpeta
|
|
118
|
+
* del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
|
|
119
|
+
* y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
|
|
120
|
+
* se sumaban 25 minutos estimados encima de una sesión medida.
|
|
116
121
|
*/
|
|
117
122
|
export declare function withoutOverlap(commitBlocks: readonly WorkBlock[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
|
|
118
123
|
//# sourceMappingURL=sessionize.d.ts.map
|
|
@@ -290,10 +290,20 @@ function sessionizeCommits(commits, options = {}) {
|
|
|
290
290
|
*
|
|
291
291
|
* Sin esto se contaría dos veces el mismo rato: el agente ya cubrió esas horas
|
|
292
292
|
* y el commit que las cerró generaría otro bloque encima.
|
|
293
|
+
*
|
|
294
|
+
* Mismo repositorio quiere decir lo mismo que en `attachCommits`: la carpeta
|
|
295
|
+
* del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
|
|
296
|
+
* y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
|
|
297
|
+
* se sumaban 25 minutos estimados encima de una sesión medida.
|
|
293
298
|
*/
|
|
294
299
|
function withoutOverlap(commitBlocks, agentBlocks) {
|
|
295
|
-
return commitBlocks.filter((c) => !agentBlocks.some((a) => a.repoPath
|
|
300
|
+
return commitBlocks.filter((c) => !agentBlocks.some((a) => sameRepo(a.repoPath, c.repoPath) &&
|
|
296
301
|
a.startedAt.getTime() < c.endedAt.getTime() &&
|
|
297
302
|
c.startedAt.getTime() < a.endedAt.getTime()));
|
|
298
303
|
}
|
|
304
|
+
function sameRepo(a, b) {
|
|
305
|
+
if (a === b)
|
|
306
|
+
return true;
|
|
307
|
+
return !!a && !!b && ((0, paths_js_1.isSameOrInside)(a, b) || (0, paths_js_1.isSameOrInside)(b, a));
|
|
308
|
+
}
|
|
299
309
|
//# sourceMappingURL=sessionize.js.map
|
package/dist/cli.js
CHANGED
|
@@ -278,9 +278,11 @@ estela — registro de horas para desarrollo asistido por IA
|
|
|
278
278
|
[--pdf <fichero.pdf>] [--csv <fichero.csv>] [--fx <tipo>]
|
|
279
279
|
[--number <nº>]
|
|
280
280
|
[--from-name <tu nombre>] [--from-email <email>]
|
|
281
|
-
[--from-tax-id <tu NIF/CIF>]
|
|
281
|
+
[--from-tax-id <tu NIF/CIF>] [--no-amounts]
|
|
282
282
|
Informe de horas y commits para respaldar tu trabajo. No es una
|
|
283
283
|
factura: Estela no emite documentos fiscales.
|
|
284
|
+
--no-amounts saca el PDF sin tarifa ni importes. En el panel, lo ya
|
|
285
|
+
emitido se vuelve a descargar con o sin ellos.
|
|
284
286
|
--number fija el número del informe; sin él se numera solo y
|
|
285
287
|
correlativo por año (INF-${new Date().getFullYear()}-001, -002...).
|
|
286
288
|
Los --from-* son tus datos como emisor, en la cabecera del PDF.
|
|
@@ -489,9 +491,11 @@ estela — time tracking for AI-assisted development
|
|
|
489
491
|
[--pdf <file.pdf>] [--csv <file.csv>] [--fx <rate>]
|
|
490
492
|
[--number <no.>]
|
|
491
493
|
[--from-name <your name>] [--from-email <email>]
|
|
492
|
-
[--from-tax-id <your tax ID>]
|
|
494
|
+
[--from-tax-id <your tax ID>] [--no-amounts]
|
|
493
495
|
Report of hours and commits to back up your work. It isn't an
|
|
494
496
|
invoice: Estela doesn't issue tax documents.
|
|
497
|
+
--no-amounts leaves the rate and amounts out of the PDF. In the panel,
|
|
498
|
+
issued reports can be downloaded again with or without them.
|
|
495
499
|
--number sets the report number; without it, reports are numbered
|
|
496
500
|
automatically and sequentially per year (INF-${new Date().getFullYear()}-001, -002...).
|
|
497
501
|
The --from-* flags are your details as the issuer, in the PDF header.
|
|
@@ -902,7 +906,7 @@ async function cmdImport(args, dbPath) {
|
|
|
902
906
|
// compañero se convertirían en tiempo facturable a tu nombre.
|
|
903
907
|
const commitsAll = (0, identity_js_1.onlyMine)(await allCommits(db), mine);
|
|
904
908
|
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(turns), commitsAll);
|
|
905
|
-
const fromCommits = (0,
|
|
909
|
+
const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commitsAll, fromAgents);
|
|
906
910
|
const raw = [...fromAgents, ...fromCommits];
|
|
907
911
|
if (fromCommits.length) {
|
|
908
912
|
console.log((0, index_js_1.tr) ` ${fromCommits.length} bloques deducidos solo de commits (sin agente)`);
|
|
@@ -2527,6 +2531,7 @@ function cmdInvoice(args, dbPath) {
|
|
|
2527
2531
|
},
|
|
2528
2532
|
} : {}),
|
|
2529
2533
|
...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
|
|
2534
|
+
withAmounts: args.flags["no-amounts"] !== true,
|
|
2530
2535
|
}));
|
|
2531
2536
|
(0, node_fs_1.writeFileSync)(pdfPath, pdf);
|
|
2532
2537
|
console.log((0, index_js_1.tr) `\nPDF: ${pdfPath} (${(pdf.length / 1024).toFixed(1)} KB, ${invoice.lines.length} conceptos)`);
|
package/dist/db/store.js
CHANGED
|
@@ -560,8 +560,14 @@ function saveTimeEntry(db, entry, at = new Date()) {
|
|
|
560
560
|
-- Y lo ya facturado es historia. Sin esta condición, un reimport
|
|
561
561
|
-- recalculaba filas que respaldan una factura emitida y dejaba el panel
|
|
562
562
|
-- diciendo una cosa y el PDF del cliente otra.
|
|
563
|
+
--
|
|
564
|
+
-- Lo aprobado, igual: aprobar un día es dar por buena esa cifra, y a
|
|
565
|
+
-- menudo ya se ha compartido (un panel publicado, un avance al cliente).
|
|
566
|
+
-- Un cambio en cómo se calculan las horas se aplica a lo que viene, no
|
|
567
|
+
-- reescribe lo que ya se dio por bueno. Para recalcularlo, se desaprueba.
|
|
563
568
|
WHERE time_entries.source IN ('agent', 'commit')
|
|
564
569
|
AND time_entries.invoice_id IS NULL
|
|
570
|
+
AND time_entries.approved = 0
|
|
565
571
|
`).run(id, entry.projectId, iso(entry.startedAt), (0, shared_1.localDate)(entry.startedAt), iso(entry.endedAt), entry.seconds, entry.description, entry.billable ? 1 : 0, entry.invoiceId, entry.aiCost.microUsd, entry.agentSeconds, entry.commitHashes.join(","), entry.agents.join(","), entry.source, entry.kind, entry.branch, (entry.workItems ?? []).join(","), iso(at));
|
|
566
572
|
return id;
|
|
567
573
|
}
|
|
@@ -15,6 +15,8 @@ export interface InvoicePdfOptions {
|
|
|
15
15
|
};
|
|
16
16
|
/** Coste real de IA imputado al periodo (cuota amortizada). Solo informativo. */
|
|
17
17
|
readonly amortizedAiCost?: Money;
|
|
18
|
+
/** Sin tarifa ni importes: solo las horas. Por defecto, con importes. */
|
|
19
|
+
readonly withAmounts?: boolean;
|
|
18
20
|
readonly footer?: string;
|
|
19
21
|
}
|
|
20
22
|
export declare function invoiceToPdf(invoice: Invoice, client: Client, project: Project, options?: InvoicePdfOptions): Buffer;
|
|
@@ -15,6 +15,9 @@ function invoiceToPdf(invoice, client, project, options = {}) {
|
|
|
15
15
|
// Los importes van con el locale del documento, no con el de la terminal: el
|
|
16
16
|
// cliente lee "€1.254,79" o "€1,254.79" según SU idioma.
|
|
17
17
|
const money = (m) => (0, shared_1.formatMoney)(m, (0, index_js_1.moneyLocale)((0, index_js_1.getLang)()));
|
|
18
|
+
const withAmounts = options.withAmounts !== false;
|
|
19
|
+
// Sin tarifa ni importe, el tiempo pasa a ser la última columna.
|
|
20
|
+
const colTime = withAmounts ? COL_TIME : COL_AMOUNT;
|
|
18
21
|
let y = M;
|
|
19
22
|
// --- Cabecera -------------------------------------------------------------
|
|
20
23
|
doc.text(M, y + 4, (0, index_js_1.tr) `INFORME DE HORAS`, "Helvetica-Bold", 20);
|
|
@@ -57,20 +60,22 @@ function invoiceToPdf(invoice, client, project, options = {}) {
|
|
|
57
60
|
// --- Cabecera de tabla ----------------------------------------------------
|
|
58
61
|
const header = () => {
|
|
59
62
|
doc.text(M, y, (0, index_js_1.tr) `CONCEPTO`, "Helvetica-Bold", 8, 0.45);
|
|
60
|
-
doc.textRight(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
doc.textRight(colTime, y, (0, index_js_1.tr) `TIEMPO`, "Helvetica-Bold", 8, 0.45);
|
|
64
|
+
if (withAmounts) {
|
|
65
|
+
doc.textRight(COL_RATE, y, (0, index_js_1.tr) `TARIFA`, "Helvetica-Bold", 8, 0.45);
|
|
66
|
+
doc.textRight(COL_AMOUNT, y, (0, index_js_1.tr) `IMPORTE`, "Helvetica-Bold", 8, 0.45);
|
|
67
|
+
}
|
|
63
68
|
y += 7;
|
|
64
69
|
doc.line(M, y, COL_AMOUNT, y, 0.8, 0.3);
|
|
65
70
|
y += 15;
|
|
66
71
|
};
|
|
67
72
|
header();
|
|
68
73
|
// --- Conceptos ------------------------------------------------------------
|
|
69
|
-
// La columna TIEMPO se alinea a la derecha en
|
|
74
|
+
// La columna TIEMPO se alinea a la derecha en colTime, así que su texto crece
|
|
70
75
|
// hacia la izquierda. Hay que restar el más ancho de esos textos, no un hueco
|
|
71
76
|
// fijo, o la descripción se le mete encima.
|
|
72
77
|
const widestTime = invoice.lines.reduce((max, l) => Math.max(max, pdf_js_1.PdfDocument.measure((0, shared_1.formatDuration)(l.seconds), "Helvetica", 10)), 0);
|
|
73
|
-
const maxDescWidth =
|
|
78
|
+
const maxDescWidth = colTime - M - widestTime - 14;
|
|
74
79
|
for (const line of invoice.lines) {
|
|
75
80
|
if (y > BOTTOM - 60) {
|
|
76
81
|
doc.newPage();
|
|
@@ -79,9 +84,11 @@ function invoiceToPdf(invoice, client, project, options = {}) {
|
|
|
79
84
|
}
|
|
80
85
|
const text = pdf_js_1.PdfDocument.truncate(line.description, "Helvetica", 10, maxDescWidth);
|
|
81
86
|
doc.text(M, y, text, "Helvetica", 10);
|
|
82
|
-
doc.textRight(
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
doc.textRight(colTime, y, (0, shared_1.formatDuration)(line.seconds), "Helvetica", 10, 0.2);
|
|
88
|
+
if (withAmounts) {
|
|
89
|
+
doc.textRight(COL_RATE, y, `${money(line.hourlyRate)}/h`, "Helvetica", 10, 0.4);
|
|
90
|
+
doc.textRight(COL_AMOUNT, y, money(line.amount), "Helvetica", 10);
|
|
91
|
+
}
|
|
85
92
|
y += 9;
|
|
86
93
|
doc.line(M, y, COL_AMOUNT, y, 0.4, 0.9);
|
|
87
94
|
y += 12;
|
|
@@ -95,19 +102,22 @@ function invoiceToPdf(invoice, client, project, options = {}) {
|
|
|
95
102
|
doc.line(M, y, COL_AMOUNT, y, 0.8, 0.3);
|
|
96
103
|
y += 18;
|
|
97
104
|
doc.text(M, y, invoice.lines.length === 1 ? (0, index_js_1.tr) `1 concepto` : (0, index_js_1.tr) `${invoice.lines.length} conceptos`, "Helvetica", 9, 0.4);
|
|
98
|
-
doc.textRight(
|
|
99
|
-
|
|
105
|
+
doc.textRight(colTime, y, (0, shared_1.formatDuration)(invoice.totalSeconds), "Helvetica-Bold", 10);
|
|
106
|
+
if (withAmounts)
|
|
107
|
+
doc.textRight(COL_AMOUNT, y, money(invoice.subtotal), "Helvetica", 10);
|
|
100
108
|
y += 18;
|
|
101
|
-
if (invoice.aiCostBilled) {
|
|
109
|
+
if (withAmounts && invoice.aiCostBilled) {
|
|
102
110
|
doc.text(M, y, (0, index_js_1.tr) `Coste de IA repercutido (1 USD = ${invoice.usdFxRate} ${invoice.currency})`, "Helvetica", 9, 0.4);
|
|
103
111
|
doc.textRight(COL_AMOUNT, y, money(invoice.aiCostBilled), "Helvetica", 10);
|
|
104
112
|
y += 18;
|
|
105
113
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
if (withAmounts) {
|
|
115
|
+
y += 4;
|
|
116
|
+
doc.rect(rightCol - 20, y - 14, COL_AMOUNT - rightCol + 20, 32, 0.94);
|
|
117
|
+
doc.text(rightCol - 8, y + 2, (0, index_js_1.tr) `VALOR`, "Helvetica-Bold", 11);
|
|
118
|
+
doc.textRight(COL_AMOUNT - 8, y + 3, money(invoice.total), "Helvetica-Bold", 14);
|
|
119
|
+
y += 46;
|
|
120
|
+
}
|
|
111
121
|
// Aquí había una "NOTA INTERNA (no se comparte)" con el consumo de IA del
|
|
112
122
|
// periodo. Se quitó: esa frase era un deseo, no un mecanismo. Este PDF es
|
|
113
123
|
// justamente el que se adjunta a la factura del cliente, así que en cuanto
|
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.3.
|
|
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",
|