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.
@@ -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;
@@ -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 === c.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, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commitsAll), fromAgents);
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(COL_TIME, y, (0, index_js_1.tr) `TIEMPO`, "Helvetica-Bold", 8, 0.45);
61
- doc.textRight(COL_RATE, y, (0, index_js_1.tr) `TARIFA`, "Helvetica-Bold", 8, 0.45);
62
- doc.textRight(COL_AMOUNT, y, (0, index_js_1.tr) `IMPORTE`, "Helvetica-Bold", 8, 0.45);
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 COL_TIME, así que su texto crece
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 = COL_TIME - M - widestTime - 14;
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(COL_TIME, y, (0, shared_1.formatDuration)(line.seconds), "Helvetica", 10, 0.2);
83
- doc.textRight(COL_RATE, y, `${money(line.hourlyRate)}/h`, "Helvetica", 10, 0.4);
84
- doc.textRight(COL_AMOUNT, y, money(line.amount), "Helvetica", 10);
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(COL_TIME, y, (0, shared_1.formatDuration)(invoice.totalSeconds), "Helvetica-Bold", 10);
99
- doc.textRight(COL_AMOUNT, y, money(invoice.subtotal), "Helvetica", 10);
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
- y += 4;
107
- doc.rect(rightCol - 20, y - 14, COL_AMOUNT - rightCol + 20, 32, 0.94);
108
- doc.text(rightCol - 8, y + 2, (0, index_js_1.tr) `VALOR`, "Helvetica-Bold", 11);
109
- doc.textRight(COL_AMOUNT - 8, y + 3, money(invoice.total), "Helvetica-Bold", 14);
110
- y += 46;
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, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commits), fromAgents);
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}.pdf"`,
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
- function sendInvoice(db, res, projectId, cutoffRaw, author) {
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 = `${invoice.number}.pdf`;
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.0",
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
- <a class="btn-main invoice-dl" download
1473
- href="/api/invoices/${encodeURIComponent(f.number)}/pdf">${tr("invoices.download")}</a>
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ó: los importes y el reparto de IA quedaron congelados ese día.",
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.download": "Descargar",
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: amounts and the AI split were frozen that day.",
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.download": "Download",
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",