iobroker.time-tracker 0.7.1 → 0.7.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 +18 -9
- package/build/lib/reports/pdf.js +8 -2
- package/build/lib/reports/pdf.js.map +2 -2
- package/build/lib/web/api.js +24 -4
- package/build/lib/web/api.js.map +2 -2
- package/build/lib/web/problem.js.map +2 -2
- package/io-package.json +14 -14
- package/package.json +1 -1
- package/src-pwa/src/i18n/de.json +5 -0
- package/src-pwa/src/i18n/en.json +5 -0
- package/src-pwa/src/i18n/es.json +5 -0
- package/src-pwa/src/i18n/fr.json +5 -0
- package/src-pwa/src/i18n/it.json +5 -0
- package/src-pwa/src/i18n/nl.json +5 -0
- package/src-pwa/src/i18n/pl.json +5 -0
- package/src-pwa/src/i18n/pt.json +5 -0
- package/src-pwa/src/i18n/ru.json +5 -0
- package/src-pwa/src/i18n/uk.json +5 -0
- package/src-pwa/src/i18n/zh-cn.json +5 -0
- package/www/assets/{index-D5fSC8CA.js → index-C0z1CkCi.js} +3 -3
- package/www/assets/index-C0z1CkCi.js.map +1 -0
- package/www/index.html +1 -1
- package/www/sw.js +1 -1
- package/www/sw.js.map +1 -1
- package/www/assets/index-D5fSC8CA.js.map +0 -1
package/README.md
CHANGED
|
@@ -119,6 +119,13 @@ instead of working with them, so it does not punch (the role has no `time.punch`
|
|
|
119
119
|
back through the `employee` role — permissions are the union of the roles of an account. A `manager` punches and
|
|
120
120
|
corrects.
|
|
121
121
|
|
|
122
|
+
**One active administrator always remains:** deactivating or taking the `admin` role away from the *last* active
|
|
123
|
+
administrator is refused (problem `last_administrator`, 409) — without that role nobody could administer the
|
|
124
|
+
installation any more, and the way back would be a restart of the instance with a free `adminLogin`. The web app says
|
|
125
|
+
it before the change: the switch of the **own** account explains why an account cannot deactivate itself (`PATCH` and
|
|
126
|
+
`DELETE /users/:id` refuse it on the server as well), and the role dialog names the last administrator and keeps
|
|
127
|
+
saving locked while the role would be gone.
|
|
128
|
+
|
|
122
129
|
The **edit window** (`edit_window_days`, default 7 days) bounds what an account may do with a day of its own without
|
|
123
130
|
a decision of the administration. Since times belong to the administration, it now matters for the **offline queue**:
|
|
124
131
|
a queued punch that is older arrives as the conflict `too_old` and counts once the office accepts it. Managers and
|
|
@@ -370,6 +377,17 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
370
377
|
|
|
371
378
|
### **WORK IN PROGRESS**
|
|
372
379
|
|
|
380
|
+
### 0.7.2 (2026-09-24)
|
|
381
|
+
|
|
382
|
+
- (Alex) security: the administration cannot lock itself out any more. Deactivating or taking the admin role away from
|
|
383
|
+
the **last active administrator** is refused (problem `last_administrator`, 409), and the web app warns before it is
|
|
384
|
+
tried on the **own** account: the own switch explains why an account cannot deactivate itself, and the role dialog
|
|
385
|
+
names the last administrator and keeps saving locked
|
|
386
|
+
- (Alex) fix: the PDF statement writes the **absences** left aligned on lines of their own. They started where the last
|
|
387
|
+
cell of the day table was drawn, so every line was squeezed into the rest of that row
|
|
388
|
+
- (Alex) fix: the list of the absence types shows “visible for everybody” on a line of its own, so the long facts
|
|
389
|
+
(`reduces vacation`) cannot push it out of the row any more
|
|
390
|
+
|
|
373
391
|
### 0.7.1 (2026-09-24)
|
|
374
392
|
|
|
375
393
|
- (Alex) fix: the correction dialog in the month view of an employee shows and changes **his** punches. It asked the
|
|
@@ -412,15 +430,6 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
412
430
|
On top the administration finds a **year overview** in the absence tab: approved days per employee and month, fed by
|
|
413
431
|
its own request for the calendar year
|
|
414
432
|
|
|
415
|
-
### 0.4.4 (2026-09-23)
|
|
416
|
-
|
|
417
|
-
- (Alex) fix: the browser tests moved from `e2e/` to `test/e2e/`. The repository checker looks at the imported
|
|
418
|
-
packages of the sources and skips the `test` directory only, so `@playwright/test` (a dev dependency that the specs
|
|
419
|
-
use) was reported as `W5042` “used but not found in dependencies”. `playwright.config.ts`, the e2e workflow and the
|
|
420
|
-
docs follow the new path, the test server computes the repository root one level deeper, and the root
|
|
421
|
-
`tsconfig.json` leaves the browser tests to their own `test/e2e/tsconfig.json` — they need the DOM types the
|
|
422
|
-
adapter does not have
|
|
423
|
-
|
|
424
433
|
Older entries are kept in [`CHANGELOG_OLD.md`](CHANGELOG_OLD.md).
|
|
425
434
|
|
|
426
435
|
## Provenance
|
package/build/lib/reports/pdf.js
CHANGED
|
@@ -28,6 +28,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
28
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
29
|
var pdf_exports = {};
|
|
30
30
|
__export(pdf_exports, {
|
|
31
|
+
LAYOUT: () => LAYOUT,
|
|
31
32
|
buildMonthStatement: () => buildMonthStatement
|
|
32
33
|
});
|
|
33
34
|
module.exports = __toCommonJS(pdf_exports);
|
|
@@ -257,8 +258,9 @@ async function buildMonthStatement(input) {
|
|
|
257
258
|
{ bold: true, color: totals.balance < 0 ? "#c62828" : "#000000", topBorder: true }
|
|
258
259
|
);
|
|
259
260
|
if (input.absences.length > 0) {
|
|
261
|
+
const contentWidth = doc.page.width - 2 * LAYOUT.margin;
|
|
260
262
|
doc.moveDown(1);
|
|
261
|
-
doc.font(fonts.bold).fontSize(10).fillColor("#000000").text(labels.absences);
|
|
263
|
+
doc.font(fonts.bold).fontSize(10).fillColor("#000000").text(labels.absences, LAYOUT.margin, doc.y, { width: contentWidth });
|
|
262
264
|
doc.moveDown(0.3);
|
|
263
265
|
for (const absence of input.absences) {
|
|
264
266
|
const portion = absence.dayPortion === 0.5 ? labels.halfDay : labels.fullDay;
|
|
@@ -266,7 +268,10 @@ async function buildMonthStatement(input) {
|
|
|
266
268
|
`${absence.typeCode} - ${absence.typeName}: ${formatDate(absence.dateFrom, locale)} - ${formatDate(
|
|
267
269
|
absence.dateTo,
|
|
268
270
|
locale
|
|
269
|
-
)}, ${portion}${absence.hours === null ? "" : `, ${absence.hours} h`}
|
|
271
|
+
)}, ${portion}${absence.hours === null ? "" : `, ${absence.hours} h`}`,
|
|
272
|
+
LAYOUT.margin,
|
|
273
|
+
doc.y,
|
|
274
|
+
{ width: contentWidth }
|
|
270
275
|
);
|
|
271
276
|
}
|
|
272
277
|
}
|
|
@@ -303,6 +308,7 @@ async function buildMonthStatement(input) {
|
|
|
303
308
|
}
|
|
304
309
|
// Annotate the CommonJS export names for ESM import in node:
|
|
305
310
|
0 && (module.exports = {
|
|
311
|
+
LAYOUT,
|
|
306
312
|
buildMonthStatement
|
|
307
313
|
});
|
|
308
314
|
//# sourceMappingURL=pdf.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/reports/pdf.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Monthly work time statement as a PDF.\n *\n * `/api/reports/pdf` returns this file. It shows the same content as the Excel statement (header block, one row\n * per day, totals, absences, signature lines), drawn with `pdfkit`.\n *\n * **Fonts:** the built-in PDF fonts (`Helvetica`) only cover Latin-1, so Cyrillic (`ru`, `uk`) and Chinese\n * (`zh-cn`) cannot be rendered with them. Instead of producing empty boxes, the renderer refuses those\n * languages with a clear error until a Unicode font is configured (`report_font_path` setting, a `.ttf`/`.otf`\n * that covers the script). Latin statements work without any additional file, which keeps the adapter package\n * free of a multi-megabyte font.\n *\n * Text is written **uncompressed** (`compress: false`): a monthly statement is a few kilobytes, and an\n * uncompressed content stream keeps the file verifiable (and testable) without a PDF library.\n */\n\nimport * as fs from \"node:fs\";\nimport PDFDocument from \"pdfkit\";\nimport { ValidationError } from \"../errors\";\nimport type { ReportLabels } from \"./labels\";\nimport type { ReportInput } from \"./types\";\n\n/** Input of the PDF statement. */\nexport type PdfStatementInput = ReportInput & {\n\t/** Name of the generator, e.g. `time-tracker 0.0.1` */\n\tgenerator: string;\n\t/** Path of a Unicode font (`.ttf`/`.otf`), `null`/empty to use the built-in fonts */\n\tfontPath?: string | null;\n};\n\n/** Languages whose script the built-in PDF fonts cannot display. */\nconst NON_LATIN_LANGUAGES = new Set([\"ru\", \"uk\", \"zh-cn\"]);\n\n/** Characters WinAnsiEncoding knows in addition to Latin-1 \u2014 anything else cannot be drawn by the built-in fonts. */\nconst WIN_ANSI_EXTRAS = \"\u20AC\u201A\u0192\u201E\u2026\u2020\u2021\u02C6\u2030\u0160\u2039\u0152\u017D\u2018\u2019\u201C\u201D\u2022\u2013\u2014\u02DC\u2122\u0161\u203A\u0153\u017E\u0178\";\n\n/**\n * Reports whether the built-in fonts can draw a text.\n *\n * The built-in fonts use WinAnsiEncoding, which covers Latin-1 and a handful of typographic characters. A Polish\n * \"Nieobecno\u015B\u0107\" is outside of it: the renderer would draw nonsense instead of the word, so such a text counts as\n * undrawable.\n *\n * @param text - text to check\n * @returns true when every character is part of WinAnsiEncoding\n */\nfunction fitsBuiltInFont(text: string): boolean {\n\treturn [...text].every(character => character.charCodeAt(0) <= 0xff || WIN_ANSI_EXTRAS.includes(character));\n}\n\n/** Fonts the renderer works with. */\ninterface FontChoice {\n\t/** Font of the regular text */\n\tregular: string;\n\t/** Font of the headings */\n\tbold: string;\n\t/** True when a file of the administrator is used */\n\tembedded: boolean;\n}\n\n/** Margins and text sizes of the statement (A4 portrait, points). */\nconst LAYOUT = {\n\tmargin: 40,\n\tfontSize: 9,\n\tlineHeight: 14,\n} as const;\n\n/** Distance between two columns, so that texts never touch. */\nconst COLUMN_PADDING = 6;\n\n/**\n * Font sizes tried for the column headings, the largest first.\n *\n * A heading has to stay on one line: \"Arbeitszeit\" is about 57 points wide at 9 points, the Ukrainian\n * \"\u0412\u0456\u0434\u043F\u0440\u0430\u0446\u044C\u043E\u0432\u0430\u043D\u043E\" needs even more, and this renderer breaks a word when the column is too narrow for it (the\n * option `lineBreak: false` does not prevent that). The headings are therefore measured and the size steps down\n * until every one of them fits.\n */\nconst HEADER_SIZES = [9, 8, 7, 6.5] as const;\n\n/** The columns of the day table, in the order they are drawn. */\nconst COLUMNS: {\n\t/** Key in the values object and in the labels */\n\tkey: keyof ReportLabels;\n\t/** Alignment of the text inside the cell */\n\talign: \"left\" | \"right\";\n\t/** A typical value: the column is at least wide enough for it */\n\tsample: string;\n\t/** Share of the width left over after each column got what it needs */\n\tgrow: number;\n}[] = [\n\t{ key: \"date\", align: \"left\", sample: \"Mi., 30.09.2026\", grow: 0.35 },\n\t{ key: \"timeIn\", align: \"right\", sample: \"08:00\", grow: 0 },\n\t{ key: \"timeOut\", align: \"right\", sample: \"08:00\", grow: 0 },\n\t{ key: \"worked\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"breaks\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"paidBreaks\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"target\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"balance\", align: \"right\", sample: \"-00:00\", grow: 0 },\n\t{ key: \"absence\", align: \"left\", sample: \"F 50 %\", grow: 0 },\n\t{ key: \"note\", align: \"left\", sample: \"Feiertag, offen\", grow: 0.65 },\n];\n\n/** A column with the position and the width the renderer measured. */\ninterface MeasuredColumn {\n\t/** Key in the values object */\n\tkey: keyof ReportLabels;\n\t/** Left edge in points */\n\tx: number;\n\t/** Width in points */\n\twidth: number;\n\t/** Alignment of the text inside the cell */\n\talign: \"left\" | \"right\";\n}\n\n/**\n * Chooses the fonts of a statement.\n *\n * @param language - language key of the labels, e.g. `ru`\n * @param fontPath - configured font file\n * @param labels - labels of the employee's language\n * @returns the fonts to use\n */\nfunction chooseFonts(language: string, fontPath: string | null | undefined, labels: ReportLabels): FontChoice {\n\tconst configured = (fontPath ?? \"\").trim();\n\tif (configured !== \"\" && fs.existsSync(configured)) {\n\t\t// one file is used for regular and bold text: a statement must not depend on a second file being there\n\t\treturn { regular: configured, bold: configured, embedded: true };\n\t}\n\t// the language list is the fast path; the labels catch a language whose letters are outside WinAnsiEncoding\n\t// (Polish \"Nieobecno\u015B\u0107\", \"\u015Bwi\u0119to\" or \"ca\u0142y dzie\u0144\") \u2014 drawing them would produce nonsense, not a statement\n\tconst undrawable = Object.values(labels).filter(text => !fitsBuiltInFont(text));\n\tif (NON_LATIN_LANGUAGES.has(language) || undrawable.length > 0) {\n\t\tthrow new ValidationError(\n\t\t\t`language \"${language}\" needs a Unicode font: set report_font_path to a .ttf/.otf file that covers the script`,\n\t\t);\n\t}\n\treturn { regular: \"Helvetica\", bold: \"Helvetica-Bold\", embedded: false };\n}\n\n/**\n * Measures the columns of the day table.\n *\n * Every column is at least as wide as its heading, so a heading never has to be broken: a column sized for the\n * value \"9:30\" would otherwise split \"Arbeitszeit\" in the middle of the word. The width left over after that\n * goes to the date and to the note \u2014 the two columns that carry free text.\n *\n * @param doc - document the texts are measured on\n * @param fonts - fonts of the statement\n * @param labels - labels of the employee's language\n * @param samples - the texts of a column, so the column is wide enough for every one of them\n * @returns the columns and the font size of the heading row\n */\nfunction measureColumns(\n\tdoc: PDFKit.PDFDocument,\n\tfonts: FontChoice,\n\tlabels: ReportLabels,\n\tsamples: Partial<Record<keyof ReportLabels, string[]>>,\n): { columns: MeasuredColumn[]; headerSize: number } {\n\tconst available = doc.page.width - 2 * LAYOUT.margin;\n\tconst smallest = HEADER_SIZES.length - 1;\n\n\tfor (const [index, size] of HEADER_SIZES.entries()) {\n\t\tconst needed = COLUMNS.map(column => {\n\t\t\tdoc.font(fonts.bold).fontSize(size);\n\t\t\tconst heading = doc.widthOfString(labels[column.key]);\n\t\t\tdoc.font(fonts.regular).fontSize(LAYOUT.fontSize);\n\t\t\t// every value of the column is measured: a total is wider than a single day (\"187:00\" against\n\t\t\t// \"8:00\"), and measuring only a sample would break that number in the middle\n\t\t\tconst texts = samples[column.key] ?? [column.sample];\n\t\t\tconst value = Math.max(\n\t\t\t\t...(texts.length > 0 ? texts : [column.sample]).map(text => doc.widthOfString(text)),\n\t\t\t);\n\t\t\t// two paddings: one is the gap to the next column, the other keeps the text off the edge \u2014 a text that\n\t\t\t// fills its box to the last point is wrapped (or cut) after the last character by the renderer\n\t\t\treturn Math.max(heading, value) + 2 * COLUMN_PADDING;\n\t\t});\n\t\tconst total = needed.reduce((sum, width) => sum + width, 0);\n\t\tif (total > available && index < smallest) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst leftover = Math.max(0, available - total);\n\t\tlet x = LAYOUT.margin;\n\t\tconst columns = COLUMNS.map((column, position) => {\n\t\t\tconst width = needed[position] + leftover * column.grow;\n\t\t\tconst measured: MeasuredColumn = { key: column.key, x, width, align: column.align };\n\t\t\tx += width;\n\t\t\treturn measured;\n\t\t});\n\t\treturn { columns, headerSize: size };\n\t}\n\n\t// not reachable: the smallest size always fits\n\treturn { columns: [], headerSize: HEADER_SIZES[smallest] };\n}\n\n/**\n * Formats a local date for display.\n *\n * @param date - local date, `YYYY-MM-DD`\n * @param locale - locale of the employee\n * @returns formatted date, e.g. `Mo, 14.09.2026`\n */\nfunction formatDate(date: string, locale: string): string {\n\treturn new Intl.DateTimeFormat(locale, {\n\t\tweekday: \"short\",\n\t\tday: \"2-digit\",\n\t\tmonth: \"2-digit\",\n\t\tyear: \"numeric\",\n\t\ttimeZone: \"UTC\",\n\t}).format(new Date(`${date}T12:00:00Z`));\n}\n\n/**\n * Formats an instant as time of day in a time zone.\n *\n * @param tsUtc - UTC epoch seconds or `null`\n * @param timeZone - IANA time zone of the employee\n * @param locale - locale of the employee\n * @returns formatted time or an empty string\n */\nfunction formatTime(tsUtc: number | null, timeZone: string, locale: string): string {\n\tif (tsUtc === null) {\n\t\treturn \"\";\n\t}\n\treturn new Intl.DateTimeFormat(locale, { timeZone, hour: \"2-digit\", minute: \"2-digit\" }).format(\n\t\tnew Date(tsUtc * 1000),\n\t);\n}\n\n/**\n * Formats minutes as `H:MM`.\n *\n * @param minutes - duration in minutes\n * @returns formatted duration, e.g. `9:30`\n */\nfunction formatMinutes(minutes: number): string {\n\tconst sign = minutes < 0 ? \"-\" : \"\";\n\tconst absolute = Math.abs(minutes);\n\treturn `${sign}${Math.floor(absolute / 60)}:${String(absolute % 60).padStart(2, \"0\")}`;\n}\n\n/**\n * Formats an instant for the header block.\n *\n * @param tsUtc - UTC epoch seconds\n * @param locale - locale of the employee\n * @param timeZone - IANA time zone of the employee\n * @returns formatted date and time\n */\nfunction formatStamp(tsUtc: number, locale: string, timeZone: string): string {\n\treturn new Intl.DateTimeFormat(locale, { timeZone, dateStyle: \"short\", timeStyle: \"short\" }).format(\n\t\tnew Date(tsUtc * 1000),\n\t);\n}\n\n/**\n * Builds the monthly statement as a PDF.\n *\n * @param input - employee, month, days, absences, labels and font\n * @returns the document as bytes\n */\nexport async function buildMonthStatement(input: PdfStatementInput): Promise<Buffer> {\n\tconst { labels, locale, user } = input;\n\tconst fonts = chooseFonts(input.language, input.fontPath, labels);\n\tconst from = `${input.year}-${String(input.month).padStart(2, \"0\")}-01`;\n\tconst lastDay = new Date(Date.UTC(input.year, input.month, 0)).getUTCDate();\n\tconst to = `${input.year}-${String(input.month).padStart(2, \"0\")}-${String(lastDay).padStart(2, \"0\")}`;\n\tconst generated = formatStamp(input.generatedAt, locale, user.timezone);\n\n\tconst doc = new PDFDocument({\n\t\tsize: \"A4\",\n\t\tmargin: LAYOUT.margin,\n\t\t// an uncompressed stream keeps the statement checkable without a PDF reader\n\t\tcompress: false,\n\t\tinfo: {\n\t\t\tTitle: `${labels.title} ${input.year}-${String(input.month).padStart(2, \"0\")}`,\n\t\t\tAuthor: input.generator,\n\t\t\tCreator: input.generator,\n\t\t\tProducer: input.generator,\n\t\t\tSubject: `${user.displayName} (${user.login})`,\n\t\t},\n\t});\n\n\tconst chunks: Buffer[] = [];\n\tdoc.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n\tconst finished = new Promise<void>(resolve => doc.on(\"end\", resolve));\n\n\tconst pageBottom = doc.page.height - LAYOUT.margin - 24;\n\t/**\n\t * Returns the longer of two texts.\n\t *\n\t * @param first - first text\n\t * @param second - second text\n\t * @returns the longer text\n\t */\n\t// The values of every column are summed up here: the totals are drawn in the row below and their width is\n\t// what the columns are measured against, so a total like \"187:00\" gets the room it needs.\n\tconst totals = input.days.reduce(\n\t\t(sum, day) => ({\n\t\t\tworked: sum.worked + day.workedMin,\n\t\t\tbreaks: sum.breaks + day.breakMin,\n\t\t\tpaidBreaks: sum.paidBreaks + day.paidBreakMin,\n\t\t\ttarget: sum.target + day.targetMin,\n\t\t\tbalance: sum.balance + day.balanceMin,\n\t\t}),\n\t\t{ worked: 0, breaks: 0, paidBreaks: 0, target: 0, balance: 0 },\n\t);\n\t/** The widest time of a day: in English a time carries an \"AM\"/\"PM\" marker, so \"11:59 PM\" is what fits. */\n\tconst widestTime = formatTime(Date.UTC(2026, 0, 1, 23, 59) / 1000, \"UTC\", locale);\n\n\tconst { columns, headerSize } = measureColumns(doc, fonts, labels, {\n\t\t// the date column holds the dates of the report: \"czw., 30.09.2026\" is wider than \"Thu, 30.09.2026\"\n\t\tdate: input.days.map(day => formatDate(day.localDate, locale)),\n\t\ttimeIn: [...input.days.map(day => formatTime(day.firstInUtc, user.timezone, locale)), widestTime],\n\t\ttimeOut: [...input.days.map(day => formatTime(day.lastOutUtc, user.timezone, locale)), widestTime],\n\t\tworked: [...input.days.map(day => formatMinutes(day.workedMin)), formatMinutes(totals.worked)],\n\t\tbreaks: [...input.days.map(day => formatMinutes(day.breakMin)), formatMinutes(totals.breaks)],\n\t\tpaidBreaks: [...input.days.map(day => formatMinutes(day.paidBreakMin)), formatMinutes(totals.paidBreaks)],\n\t\ttarget: [...input.days.map(day => formatMinutes(day.targetMin)), formatMinutes(totals.target)],\n\t\tbalance: [...input.days.map(day => formatMinutes(day.balanceMin)), formatMinutes(totals.balance)],\n\t\t// the note column carries the marks of a day: a public holiday and a punch without a counterpart\n\t\tnote: [labels.holiday, labels.openEntry, `${labels.holiday}, ${labels.openEntry}`],\n\t});\n\n\t/**\n\t * Draws one line of the day table.\n\t *\n\t * @param values - column values by key\n\t * @param options - styling\n\t * @param options.bold - true for headings and totals\n\t * @param options.color - text colour\n\t * @param options.topBorder - true to draw a line above the row\n\t * @param options.fontSize - text size, defaults to the size of the rows\n\t */\n\tconst drawRow = (\n\t\tvalues: Partial<Record<keyof ReportLabels, string>>,\n\t\toptions: { bold?: boolean; color?: string; topBorder?: boolean; fontSize?: number } = {},\n\t): void => {\n\t\tconst y = doc.y;\n\t\tif (options.topBorder) {\n\t\t\tdoc.moveTo(LAYOUT.margin, y - 3)\n\t\t\t\t.lineTo(doc.page.width - LAYOUT.margin, y - 3)\n\t\t\t\t.lineWidth(0.5)\n\t\t\t\t.strokeColor(\"#9aa5b1\")\n\t\t\t\t.stroke();\n\t\t\tdoc.y = y;\n\t\t}\n\t\tdoc.font(options.bold ? fonts.bold : fonts.regular).fontSize(options.fontSize ?? LAYOUT.fontSize);\n\t\tdoc.fillColor(options.color ?? \"#000000\");\n\t\tfor (const column of columns) {\n\t\t\t// the width of the drawn box is one padding narrower than the column: without that gap a right\n\t\t\t// aligned value would end exactly where the next column starts (\"SaldoAbwesenheit\")\n\t\t\tdoc.text(values[column.key] ?? \"\", column.x, y, {\n\t\t\t\twidth: Math.max(1, column.width - COLUMN_PADDING),\n\t\t\t\talign: column.align,\n\t\t\t\tlineBreak: false,\n\t\t\t});\n\t\t}\n\t\tdoc.y = y + LAYOUT.lineHeight;\n\t};\n\n\t/** Draws the column headings and the line below them. */\n\tconst drawHeaderRow = (): void => {\n\t\tconst headings: Partial<Record<keyof ReportLabels, string>> = {};\n\t\tfor (const column of columns) {\n\t\t\theadings[column.key] = labels[column.key];\n\t\t}\n\t\tdrawRow(headings, { bold: true, fontSize: headerSize });\n\t\tdoc.moveTo(LAYOUT.margin, doc.y - 3)\n\t\t\t.lineTo(doc.page.width - LAYOUT.margin, doc.y - 3)\n\t\t\t.lineWidth(0.5)\n\t\t\t.strokeColor(\"#9aa5b1\")\n\t\t\t.stroke();\n\t};\n\n\t// --- header block ---------------------------------------------------------\n\tdoc.font(fonts.bold).fontSize(16).fillColor(\"#000000\").text(labels.title);\n\tdoc.moveDown(0.3);\n\tdoc.font(fonts.regular).fontSize(10);\n\tdoc.text(`${labels.employee}: ${user.displayName} (${user.login})`);\n\tdoc.text(`${labels.period}: ${formatDate(from, locale)} - ${formatDate(to, locale)}`);\n\tdoc.text(`${labels.created}: ${generated}`);\n\tdoc.moveDown(0.8);\n\n\t// --- days -----------------------------------------------------------------\n\tdrawHeaderRow();\n\n\tfor (const day of input.days) {\n\t\t// one more page when the month does not fit (defensive: 31 rows always fit on one A4 page)\n\t\tif (doc.y + LAYOUT.lineHeight > pageBottom) {\n\t\t\tdoc.addPage();\n\t\t\tdoc.font(fonts.regular).fontSize(8).fillColor(\"#757575\").text(labels.title, LAYOUT.margin, LAYOUT.margin);\n\t\t\tdoc.moveDown(0.5);\n\t\t\tdrawHeaderRow();\n\t\t}\n\n\t\tconst notes: string[] = [];\n\t\tif (day.isHoliday) {\n\t\t\tnotes.push(labels.holiday);\n\t\t}\n\t\tif (day.hasOpenEntry) {\n\t\t\tnotes.push(labels.openEntry);\n\t\t}\n\n\t\tdrawRow(\n\t\t\t{\n\t\t\t\tdate: formatDate(day.localDate, locale),\n\t\t\t\ttimeIn: formatTime(day.firstInUtc, user.timezone, locale),\n\t\t\t\ttimeOut: formatTime(day.lastOutUtc, user.timezone, locale),\n\t\t\t\tworked: formatMinutes(day.workedMin),\n\t\t\t\tbreaks: formatMinutes(day.breakMin),\n\t\t\t\tpaidBreaks: formatMinutes(day.paidBreakMin),\n\t\t\t\ttarget: formatMinutes(day.targetMin),\n\t\t\t\tbalance: formatMinutes(day.balanceMin),\n\t\t\t\tabsence: day.absenceCode ?? \"\",\n\t\t\t\tnote: notes.join(\", \"),\n\t\t\t},\n\t\t\t{\n\t\t\t\tcolor: day.balanceMin < 0 ? \"#c62828\" : day.isHoliday || day.workedMin === 0 ? \"#757575\" : \"#000000\",\n\t\t\t},\n\t\t);\n\t}\n\n\tdrawRow(\n\t\t{\n\t\t\tdate: labels.total,\n\t\t\tworked: formatMinutes(totals.worked),\n\t\t\tbreaks: formatMinutes(totals.breaks),\n\t\t\tpaidBreaks: formatMinutes(totals.paidBreaks),\n\t\t\ttarget: formatMinutes(totals.target),\n\t\t\tbalance: formatMinutes(totals.balance),\n\t\t\tabsence: `${labels.days}: ${input.days.length}`,\n\t\t},\n\t\t{ bold: true, color: totals.balance < 0 ? \"#c62828\" : \"#000000\", topBorder: true },\n\t);\n\n\t// --- absences -------------------------------------------------------------\n\tif (input.absences.length > 0) {\n\t\tdoc.moveDown(1);\n\t\tdoc.font(fonts.bold).fontSize(10).fillColor(\"#000000\").text(labels.absences);\n\t\tdoc.moveDown(0.3);\n\t\tfor (const absence of input.absences) {\n\t\t\tconst portion = absence.dayPortion === 0.5 ? labels.halfDay : labels.fullDay;\n\t\t\tdoc.font(fonts.regular)\n\t\t\t\t.fontSize(LAYOUT.fontSize)\n\t\t\t\t.fillColor(\"#000000\")\n\t\t\t\t.text(\n\t\t\t\t\t`${absence.typeCode} - ${absence.typeName}: ${formatDate(absence.dateFrom, locale)} - ${formatDate(\n\t\t\t\t\t\tabsence.dateTo,\n\t\t\t\t\t\tlocale,\n\t\t\t\t\t)}, ${portion}${absence.hours === null ? \"\" : `, ${absence.hours} h`}`,\n\t\t\t\t);\n\t\t}\n\t}\n\n\t// --- signatures -----------------------------------------------------------\n\t// the label is drawn and the line is ruled: a long label plus a string of underscores is wrapped by the\n\t// renderer (the Russian and Ukrainian labels are long), and then the two signature lines are no longer level\n\t//\n\t// The block sits near the bottom of the page: a statement ends with two signature lines, so an empty area\n\t// above them looks like the document was cut off. It stays below the content when that reaches far down.\n\tconst signatureGap = 16;\n\tconst signatureWidth = (doc.page.width - 2 * LAYOUT.margin - signatureGap) / 2;\n\tconst signaturePreferred = doc.page.height - 30 - LAYOUT.lineHeight - 24;\n\tconst signatureY = Math.max(doc.y + 24, Math.min(signaturePreferred, pageBottom - LAYOUT.lineHeight));\n\n\t/**\n\t * Draws one signature line: the label followed by the rule to sign on.\n\t *\n\t * @param label - text before the line\n\t * @param x - left edge of the block\n\t */\n\tconst drawSignature = (label: string, x: number): void => {\n\t\tdoc.font(fonts.regular).fontSize(LAYOUT.fontSize).fillColor(\"#000000\");\n\t\tconst written = `${label}:`;\n\t\t// without a width limit the label is never broken \u2014 the block is wide enough for all eleven languages\n\t\tdoc.text(written, x, signatureY, { lineBreak: false });\n\t\tconst start = Math.min(x + doc.widthOfString(written) + 6, x + signatureWidth - 24);\n\t\tdoc.moveTo(start, signatureY + LAYOUT.fontSize + 2)\n\t\t\t.lineTo(x + signatureWidth, signatureY + LAYOUT.fontSize + 2)\n\t\t\t.lineWidth(0.5)\n\t\t\t.strokeColor(\"#000000\")\n\t\t\t.stroke();\n\t\tdoc.y = signatureY + LAYOUT.lineHeight;\n\t};\n\n\tdrawSignature(labels.signatureEmployee, LAYOUT.margin);\n\tdrawSignature(labels.signatureManager, LAYOUT.margin + signatureWidth + signatureGap);\n\n\t// --- footer on every page -------------------------------------------------\n\t// the footer sits below the writing area on purpose, so the bottom margin is lifted while it is drawn: PDFKit\n\t// starts a new page when a text crosses the margin, and that page would carry nothing but the footer\n\tconst range = doc.bufferedPageRange();\n\tfor (let index = 0; index < range.count; index++) {\n\t\tdoc.switchToPage(range.start + index);\n\t\tconst bottom = doc.page.margins.bottom;\n\t\tdoc.page.margins.bottom = 0;\n\t\tdoc.font(fonts.regular)\n\t\t\t.fontSize(8)\n\t\t\t.fillColor(\"#757575\")\n\t\t\t.text(\n\t\t\t\t`${input.generator} - ${labels.title} ${input.year}-${String(input.month).padStart(2, \"0\")} - ${\n\t\t\t\t\tindex + 1\n\t\t\t\t}/${range.count}`,\n\t\t\t\tLAYOUT.margin,\n\t\t\t\tdoc.page.height - 30,\n\t\t\t\t{ width: doc.page.width - 2 * LAYOUT.margin, align: \"center\", lineBreak: false },\n\t\t\t);\n\t\tdoc.page.margins.bottom = bottom;\n\t}\n\n\tdoc.end();\n\tawait finished;\n\treturn Buffer.concat(chunks);\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAgBA,SAAoB;AACpB,oBAAwB;AACxB,oBAAgC;AAahC,MAAM,sBAAsB,oBAAI,IAAI,CAAC,MAAM,MAAM,OAAO,CAAC;AAGzD,MAAM,kBAAkB;AAYxB,SAAS,gBAAgB,MAAuB;AAC/C,SAAO,CAAC,GAAG,IAAI,EAAE,MAAM,eAAa,UAAU,WAAW,CAAC,KAAK,OAAQ,gBAAgB,SAAS,SAAS,CAAC;AAC3G;
|
|
4
|
+
"sourcesContent": ["/**\n * Monthly work time statement as a PDF.\n *\n * `/api/reports/pdf` returns this file. It shows the same content as the Excel statement (header block, one row\n * per day, totals, absences, signature lines), drawn with `pdfkit`.\n *\n * **Fonts:** the built-in PDF fonts (`Helvetica`) only cover Latin-1, so Cyrillic (`ru`, `uk`) and Chinese\n * (`zh-cn`) cannot be rendered with them. Instead of producing empty boxes, the renderer refuses those\n * languages with a clear error until a Unicode font is configured (`report_font_path` setting, a `.ttf`/`.otf`\n * that covers the script). Latin statements work without any additional file, which keeps the adapter package\n * free of a multi-megabyte font.\n *\n * Text is written **uncompressed** (`compress: false`): a monthly statement is a few kilobytes, and an\n * uncompressed content stream keeps the file verifiable (and testable) without a PDF library.\n */\n\nimport * as fs from \"node:fs\";\nimport PDFDocument from \"pdfkit\";\nimport { ValidationError } from \"../errors\";\nimport type { ReportLabels } from \"./labels\";\nimport type { ReportInput } from \"./types\";\n\n/** Input of the PDF statement. */\nexport type PdfStatementInput = ReportInput & {\n\t/** Name of the generator, e.g. `time-tracker 0.0.1` */\n\tgenerator: string;\n\t/** Path of a Unicode font (`.ttf`/`.otf`), `null`/empty to use the built-in fonts */\n\tfontPath?: string | null;\n};\n\n/** Languages whose script the built-in PDF fonts cannot display. */\nconst NON_LATIN_LANGUAGES = new Set([\"ru\", \"uk\", \"zh-cn\"]);\n\n/** Characters WinAnsiEncoding knows in addition to Latin-1 \u2014 anything else cannot be drawn by the built-in fonts. */\nconst WIN_ANSI_EXTRAS = \"\u20AC\u201A\u0192\u201E\u2026\u2020\u2021\u02C6\u2030\u0160\u2039\u0152\u017D\u2018\u2019\u201C\u201D\u2022\u2013\u2014\u02DC\u2122\u0161\u203A\u0153\u017E\u0178\";\n\n/**\n * Reports whether the built-in fonts can draw a text.\n *\n * The built-in fonts use WinAnsiEncoding, which covers Latin-1 and a handful of typographic characters. A Polish\n * \"Nieobecno\u015B\u0107\" is outside of it: the renderer would draw nonsense instead of the word, so such a text counts as\n * undrawable.\n *\n * @param text - text to check\n * @returns true when every character is part of WinAnsiEncoding\n */\nfunction fitsBuiltInFont(text: string): boolean {\n\treturn [...text].every(character => character.charCodeAt(0) <= 0xff || WIN_ANSI_EXTRAS.includes(character));\n}\n\n/** Fonts the renderer works with. */\ninterface FontChoice {\n\t/** Font of the regular text */\n\tregular: string;\n\t/** Font of the headings */\n\tbold: string;\n\t/** True when a file of the administrator is used */\n\tembedded: boolean;\n}\n\n/** Margins and text sizes of the statement (A4 portrait, points). */\nexport const LAYOUT = {\n\tmargin: 40,\n\tfontSize: 9,\n\tlineHeight: 14,\n} as const;\n\n/** Distance between two columns, so that texts never touch. */\nconst COLUMN_PADDING = 6;\n\n/**\n * Font sizes tried for the column headings, the largest first.\n *\n * A heading has to stay on one line: \"Arbeitszeit\" is about 57 points wide at 9 points, the Ukrainian\n * \"\u0412\u0456\u0434\u043F\u0440\u0430\u0446\u044C\u043E\u0432\u0430\u043D\u043E\" needs even more, and this renderer breaks a word when the column is too narrow for it (the\n * option `lineBreak: false` does not prevent that). The headings are therefore measured and the size steps down\n * until every one of them fits.\n */\nconst HEADER_SIZES = [9, 8, 7, 6.5] as const;\n\n/** The columns of the day table, in the order they are drawn. */\nconst COLUMNS: {\n\t/** Key in the values object and in the labels */\n\tkey: keyof ReportLabels;\n\t/** Alignment of the text inside the cell */\n\talign: \"left\" | \"right\";\n\t/** A typical value: the column is at least wide enough for it */\n\tsample: string;\n\t/** Share of the width left over after each column got what it needs */\n\tgrow: number;\n}[] = [\n\t{ key: \"date\", align: \"left\", sample: \"Mi., 30.09.2026\", grow: 0.35 },\n\t{ key: \"timeIn\", align: \"right\", sample: \"08:00\", grow: 0 },\n\t{ key: \"timeOut\", align: \"right\", sample: \"08:00\", grow: 0 },\n\t{ key: \"worked\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"breaks\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"paidBreaks\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"target\", align: \"right\", sample: \"00:00\", grow: 0 },\n\t{ key: \"balance\", align: \"right\", sample: \"-00:00\", grow: 0 },\n\t{ key: \"absence\", align: \"left\", sample: \"F 50 %\", grow: 0 },\n\t{ key: \"note\", align: \"left\", sample: \"Feiertag, offen\", grow: 0.65 },\n];\n\n/** A column with the position and the width the renderer measured. */\ninterface MeasuredColumn {\n\t/** Key in the values object */\n\tkey: keyof ReportLabels;\n\t/** Left edge in points */\n\tx: number;\n\t/** Width in points */\n\twidth: number;\n\t/** Alignment of the text inside the cell */\n\talign: \"left\" | \"right\";\n}\n\n/**\n * Chooses the fonts of a statement.\n *\n * @param language - language key of the labels, e.g. `ru`\n * @param fontPath - configured font file\n * @param labels - labels of the employee's language\n * @returns the fonts to use\n */\nfunction chooseFonts(language: string, fontPath: string | null | undefined, labels: ReportLabels): FontChoice {\n\tconst configured = (fontPath ?? \"\").trim();\n\tif (configured !== \"\" && fs.existsSync(configured)) {\n\t\t// one file is used for regular and bold text: a statement must not depend on a second file being there\n\t\treturn { regular: configured, bold: configured, embedded: true };\n\t}\n\t// the language list is the fast path; the labels catch a language whose letters are outside WinAnsiEncoding\n\t// (Polish \"Nieobecno\u015B\u0107\", \"\u015Bwi\u0119to\" or \"ca\u0142y dzie\u0144\") \u2014 drawing them would produce nonsense, not a statement\n\tconst undrawable = Object.values(labels).filter(text => !fitsBuiltInFont(text));\n\tif (NON_LATIN_LANGUAGES.has(language) || undrawable.length > 0) {\n\t\tthrow new ValidationError(\n\t\t\t`language \"${language}\" needs a Unicode font: set report_font_path to a .ttf/.otf file that covers the script`,\n\t\t);\n\t}\n\treturn { regular: \"Helvetica\", bold: \"Helvetica-Bold\", embedded: false };\n}\n\n/**\n * Measures the columns of the day table.\n *\n * Every column is at least as wide as its heading, so a heading never has to be broken: a column sized for the\n * value \"9:30\" would otherwise split \"Arbeitszeit\" in the middle of the word. The width left over after that\n * goes to the date and to the note \u2014 the two columns that carry free text.\n *\n * @param doc - document the texts are measured on\n * @param fonts - fonts of the statement\n * @param labels - labels of the employee's language\n * @param samples - the texts of a column, so the column is wide enough for every one of them\n * @returns the columns and the font size of the heading row\n */\nfunction measureColumns(\n\tdoc: PDFKit.PDFDocument,\n\tfonts: FontChoice,\n\tlabels: ReportLabels,\n\tsamples: Partial<Record<keyof ReportLabels, string[]>>,\n): { columns: MeasuredColumn[]; headerSize: number } {\n\tconst available = doc.page.width - 2 * LAYOUT.margin;\n\tconst smallest = HEADER_SIZES.length - 1;\n\n\tfor (const [index, size] of HEADER_SIZES.entries()) {\n\t\tconst needed = COLUMNS.map(column => {\n\t\t\tdoc.font(fonts.bold).fontSize(size);\n\t\t\tconst heading = doc.widthOfString(labels[column.key]);\n\t\t\tdoc.font(fonts.regular).fontSize(LAYOUT.fontSize);\n\t\t\t// every value of the column is measured: a total is wider than a single day (\"187:00\" against\n\t\t\t// \"8:00\"), and measuring only a sample would break that number in the middle\n\t\t\tconst texts = samples[column.key] ?? [column.sample];\n\t\t\tconst value = Math.max(\n\t\t\t\t...(texts.length > 0 ? texts : [column.sample]).map(text => doc.widthOfString(text)),\n\t\t\t);\n\t\t\t// two paddings: one is the gap to the next column, the other keeps the text off the edge \u2014 a text that\n\t\t\t// fills its box to the last point is wrapped (or cut) after the last character by the renderer\n\t\t\treturn Math.max(heading, value) + 2 * COLUMN_PADDING;\n\t\t});\n\t\tconst total = needed.reduce((sum, width) => sum + width, 0);\n\t\tif (total > available && index < smallest) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst leftover = Math.max(0, available - total);\n\t\tlet x = LAYOUT.margin;\n\t\tconst columns = COLUMNS.map((column, position) => {\n\t\t\tconst width = needed[position] + leftover * column.grow;\n\t\t\tconst measured: MeasuredColumn = { key: column.key, x, width, align: column.align };\n\t\t\tx += width;\n\t\t\treturn measured;\n\t\t});\n\t\treturn { columns, headerSize: size };\n\t}\n\n\t// not reachable: the smallest size always fits\n\treturn { columns: [], headerSize: HEADER_SIZES[smallest] };\n}\n\n/**\n * Formats a local date for display.\n *\n * @param date - local date, `YYYY-MM-DD`\n * @param locale - locale of the employee\n * @returns formatted date, e.g. `Mo, 14.09.2026`\n */\nfunction formatDate(date: string, locale: string): string {\n\treturn new Intl.DateTimeFormat(locale, {\n\t\tweekday: \"short\",\n\t\tday: \"2-digit\",\n\t\tmonth: \"2-digit\",\n\t\tyear: \"numeric\",\n\t\ttimeZone: \"UTC\",\n\t}).format(new Date(`${date}T12:00:00Z`));\n}\n\n/**\n * Formats an instant as time of day in a time zone.\n *\n * @param tsUtc - UTC epoch seconds or `null`\n * @param timeZone - IANA time zone of the employee\n * @param locale - locale of the employee\n * @returns formatted time or an empty string\n */\nfunction formatTime(tsUtc: number | null, timeZone: string, locale: string): string {\n\tif (tsUtc === null) {\n\t\treturn \"\";\n\t}\n\treturn new Intl.DateTimeFormat(locale, { timeZone, hour: \"2-digit\", minute: \"2-digit\" }).format(\n\t\tnew Date(tsUtc * 1000),\n\t);\n}\n\n/**\n * Formats minutes as `H:MM`.\n *\n * @param minutes - duration in minutes\n * @returns formatted duration, e.g. `9:30`\n */\nfunction formatMinutes(minutes: number): string {\n\tconst sign = minutes < 0 ? \"-\" : \"\";\n\tconst absolute = Math.abs(minutes);\n\treturn `${sign}${Math.floor(absolute / 60)}:${String(absolute % 60).padStart(2, \"0\")}`;\n}\n\n/**\n * Formats an instant for the header block.\n *\n * @param tsUtc - UTC epoch seconds\n * @param locale - locale of the employee\n * @param timeZone - IANA time zone of the employee\n * @returns formatted date and time\n */\nfunction formatStamp(tsUtc: number, locale: string, timeZone: string): string {\n\treturn new Intl.DateTimeFormat(locale, { timeZone, dateStyle: \"short\", timeStyle: \"short\" }).format(\n\t\tnew Date(tsUtc * 1000),\n\t);\n}\n\n/**\n * Builds the monthly statement as a PDF.\n *\n * @param input - employee, month, days, absences, labels and font\n * @returns the document as bytes\n */\nexport async function buildMonthStatement(input: PdfStatementInput): Promise<Buffer> {\n\tconst { labels, locale, user } = input;\n\tconst fonts = chooseFonts(input.language, input.fontPath, labels);\n\tconst from = `${input.year}-${String(input.month).padStart(2, \"0\")}-01`;\n\tconst lastDay = new Date(Date.UTC(input.year, input.month, 0)).getUTCDate();\n\tconst to = `${input.year}-${String(input.month).padStart(2, \"0\")}-${String(lastDay).padStart(2, \"0\")}`;\n\tconst generated = formatStamp(input.generatedAt, locale, user.timezone);\n\n\tconst doc = new PDFDocument({\n\t\tsize: \"A4\",\n\t\tmargin: LAYOUT.margin,\n\t\t// an uncompressed stream keeps the statement checkable without a PDF reader\n\t\tcompress: false,\n\t\tinfo: {\n\t\t\tTitle: `${labels.title} ${input.year}-${String(input.month).padStart(2, \"0\")}`,\n\t\t\tAuthor: input.generator,\n\t\t\tCreator: input.generator,\n\t\t\tProducer: input.generator,\n\t\t\tSubject: `${user.displayName} (${user.login})`,\n\t\t},\n\t});\n\n\tconst chunks: Buffer[] = [];\n\tdoc.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n\tconst finished = new Promise<void>(resolve => doc.on(\"end\", resolve));\n\n\tconst pageBottom = doc.page.height - LAYOUT.margin - 24;\n\t/**\n\t * Returns the longer of two texts.\n\t *\n\t * @param first - first text\n\t * @param second - second text\n\t * @returns the longer text\n\t */\n\t// The values of every column are summed up here: the totals are drawn in the row below and their width is\n\t// what the columns are measured against, so a total like \"187:00\" gets the room it needs.\n\tconst totals = input.days.reduce(\n\t\t(sum, day) => ({\n\t\t\tworked: sum.worked + day.workedMin,\n\t\t\tbreaks: sum.breaks + day.breakMin,\n\t\t\tpaidBreaks: sum.paidBreaks + day.paidBreakMin,\n\t\t\ttarget: sum.target + day.targetMin,\n\t\t\tbalance: sum.balance + day.balanceMin,\n\t\t}),\n\t\t{ worked: 0, breaks: 0, paidBreaks: 0, target: 0, balance: 0 },\n\t);\n\t/** The widest time of a day: in English a time carries an \"AM\"/\"PM\" marker, so \"11:59 PM\" is what fits. */\n\tconst widestTime = formatTime(Date.UTC(2026, 0, 1, 23, 59) / 1000, \"UTC\", locale);\n\n\tconst { columns, headerSize } = measureColumns(doc, fonts, labels, {\n\t\t// the date column holds the dates of the report: \"czw., 30.09.2026\" is wider than \"Thu, 30.09.2026\"\n\t\tdate: input.days.map(day => formatDate(day.localDate, locale)),\n\t\ttimeIn: [...input.days.map(day => formatTime(day.firstInUtc, user.timezone, locale)), widestTime],\n\t\ttimeOut: [...input.days.map(day => formatTime(day.lastOutUtc, user.timezone, locale)), widestTime],\n\t\tworked: [...input.days.map(day => formatMinutes(day.workedMin)), formatMinutes(totals.worked)],\n\t\tbreaks: [...input.days.map(day => formatMinutes(day.breakMin)), formatMinutes(totals.breaks)],\n\t\tpaidBreaks: [...input.days.map(day => formatMinutes(day.paidBreakMin)), formatMinutes(totals.paidBreaks)],\n\t\ttarget: [...input.days.map(day => formatMinutes(day.targetMin)), formatMinutes(totals.target)],\n\t\tbalance: [...input.days.map(day => formatMinutes(day.balanceMin)), formatMinutes(totals.balance)],\n\t\t// the note column carries the marks of a day: a public holiday and a punch without a counterpart\n\t\tnote: [labels.holiday, labels.openEntry, `${labels.holiday}, ${labels.openEntry}`],\n\t});\n\n\t/**\n\t * Draws one line of the day table.\n\t *\n\t * @param values - column values by key\n\t * @param options - styling\n\t * @param options.bold - true for headings and totals\n\t * @param options.color - text colour\n\t * @param options.topBorder - true to draw a line above the row\n\t * @param options.fontSize - text size, defaults to the size of the rows\n\t */\n\tconst drawRow = (\n\t\tvalues: Partial<Record<keyof ReportLabels, string>>,\n\t\toptions: { bold?: boolean; color?: string; topBorder?: boolean; fontSize?: number } = {},\n\t): void => {\n\t\tconst y = doc.y;\n\t\tif (options.topBorder) {\n\t\t\tdoc.moveTo(LAYOUT.margin, y - 3)\n\t\t\t\t.lineTo(doc.page.width - LAYOUT.margin, y - 3)\n\t\t\t\t.lineWidth(0.5)\n\t\t\t\t.strokeColor(\"#9aa5b1\")\n\t\t\t\t.stroke();\n\t\t\tdoc.y = y;\n\t\t}\n\t\tdoc.font(options.bold ? fonts.bold : fonts.regular).fontSize(options.fontSize ?? LAYOUT.fontSize);\n\t\tdoc.fillColor(options.color ?? \"#000000\");\n\t\tfor (const column of columns) {\n\t\t\t// the width of the drawn box is one padding narrower than the column: without that gap a right\n\t\t\t// aligned value would end exactly where the next column starts (\"SaldoAbwesenheit\")\n\t\t\tdoc.text(values[column.key] ?? \"\", column.x, y, {\n\t\t\t\twidth: Math.max(1, column.width - COLUMN_PADDING),\n\t\t\t\talign: column.align,\n\t\t\t\tlineBreak: false,\n\t\t\t});\n\t\t}\n\t\tdoc.y = y + LAYOUT.lineHeight;\n\t};\n\n\t/** Draws the column headings and the line below them. */\n\tconst drawHeaderRow = (): void => {\n\t\tconst headings: Partial<Record<keyof ReportLabels, string>> = {};\n\t\tfor (const column of columns) {\n\t\t\theadings[column.key] = labels[column.key];\n\t\t}\n\t\tdrawRow(headings, { bold: true, fontSize: headerSize });\n\t\tdoc.moveTo(LAYOUT.margin, doc.y - 3)\n\t\t\t.lineTo(doc.page.width - LAYOUT.margin, doc.y - 3)\n\t\t\t.lineWidth(0.5)\n\t\t\t.strokeColor(\"#9aa5b1\")\n\t\t\t.stroke();\n\t};\n\n\t// --- header block ---------------------------------------------------------\n\tdoc.font(fonts.bold).fontSize(16).fillColor(\"#000000\").text(labels.title);\n\tdoc.moveDown(0.3);\n\tdoc.font(fonts.regular).fontSize(10);\n\tdoc.text(`${labels.employee}: ${user.displayName} (${user.login})`);\n\tdoc.text(`${labels.period}: ${formatDate(from, locale)} - ${formatDate(to, locale)}`);\n\tdoc.text(`${labels.created}: ${generated}`);\n\tdoc.moveDown(0.8);\n\n\t// --- days -----------------------------------------------------------------\n\tdrawHeaderRow();\n\n\tfor (const day of input.days) {\n\t\t// one more page when the month does not fit (defensive: 31 rows always fit on one A4 page)\n\t\tif (doc.y + LAYOUT.lineHeight > pageBottom) {\n\t\t\tdoc.addPage();\n\t\t\tdoc.font(fonts.regular).fontSize(8).fillColor(\"#757575\").text(labels.title, LAYOUT.margin, LAYOUT.margin);\n\t\t\tdoc.moveDown(0.5);\n\t\t\tdrawHeaderRow();\n\t\t}\n\n\t\tconst notes: string[] = [];\n\t\tif (day.isHoliday) {\n\t\t\tnotes.push(labels.holiday);\n\t\t}\n\t\tif (day.hasOpenEntry) {\n\t\t\tnotes.push(labels.openEntry);\n\t\t}\n\n\t\tdrawRow(\n\t\t\t{\n\t\t\t\tdate: formatDate(day.localDate, locale),\n\t\t\t\ttimeIn: formatTime(day.firstInUtc, user.timezone, locale),\n\t\t\t\ttimeOut: formatTime(day.lastOutUtc, user.timezone, locale),\n\t\t\t\tworked: formatMinutes(day.workedMin),\n\t\t\t\tbreaks: formatMinutes(day.breakMin),\n\t\t\t\tpaidBreaks: formatMinutes(day.paidBreakMin),\n\t\t\t\ttarget: formatMinutes(day.targetMin),\n\t\t\t\tbalance: formatMinutes(day.balanceMin),\n\t\t\t\tabsence: day.absenceCode ?? \"\",\n\t\t\t\tnote: notes.join(\", \"),\n\t\t\t},\n\t\t\t{\n\t\t\t\tcolor: day.balanceMin < 0 ? \"#c62828\" : day.isHoliday || day.workedMin === 0 ? \"#757575\" : \"#000000\",\n\t\t\t},\n\t\t);\n\t}\n\n\tdrawRow(\n\t\t{\n\t\t\tdate: labels.total,\n\t\t\tworked: formatMinutes(totals.worked),\n\t\t\tbreaks: formatMinutes(totals.breaks),\n\t\t\tpaidBreaks: formatMinutes(totals.paidBreaks),\n\t\t\ttarget: formatMinutes(totals.target),\n\t\t\tbalance: formatMinutes(totals.balance),\n\t\t\tabsence: `${labels.days}: ${input.days.length}`,\n\t\t},\n\t\t{ bold: true, color: totals.balance < 0 ? \"#c62828\" : \"#000000\", topBorder: true },\n\t);\n\n\t// --- absences -------------------------------------------------------------\n\tif (input.absences.length > 0) {\n\t\t// The block starts at the left margin over the whole width: after the table the drawing position sits inside\n\t\t// the page (the last cell was written there), and every line would be squeezed into the rest of that row.\n\t\tconst contentWidth = doc.page.width - 2 * LAYOUT.margin;\n\t\tdoc.moveDown(1);\n\t\tdoc.font(fonts.bold)\n\t\t\t.fontSize(10)\n\t\t\t.fillColor(\"#000000\")\n\t\t\t.text(labels.absences, LAYOUT.margin, doc.y, { width: contentWidth });\n\t\tdoc.moveDown(0.3);\n\t\tfor (const absence of input.absences) {\n\t\t\tconst portion = absence.dayPortion === 0.5 ? labels.halfDay : labels.fullDay;\n\t\t\tdoc.font(fonts.regular)\n\t\t\t\t.fontSize(LAYOUT.fontSize)\n\t\t\t\t.fillColor(\"#000000\")\n\t\t\t\t.text(\n\t\t\t\t\t`${absence.typeCode} - ${absence.typeName}: ${formatDate(absence.dateFrom, locale)} - ${formatDate(\n\t\t\t\t\t\tabsence.dateTo,\n\t\t\t\t\t\tlocale,\n\t\t\t\t\t)}, ${portion}${absence.hours === null ? \"\" : `, ${absence.hours} h`}`,\n\t\t\t\t\tLAYOUT.margin,\n\t\t\t\t\tdoc.y,\n\t\t\t\t\t{ width: contentWidth },\n\t\t\t\t);\n\t\t}\n\t}\n\n\t// --- signatures -----------------------------------------------------------\n\t// the label is drawn and the line is ruled: a long label plus a string of underscores is wrapped by the\n\t// renderer (the Russian and Ukrainian labels are long), and then the two signature lines are no longer level\n\t//\n\t// The block sits near the bottom of the page: a statement ends with two signature lines, so an empty area\n\t// above them looks like the document was cut off. It stays below the content when that reaches far down.\n\tconst signatureGap = 16;\n\tconst signatureWidth = (doc.page.width - 2 * LAYOUT.margin - signatureGap) / 2;\n\tconst signaturePreferred = doc.page.height - 30 - LAYOUT.lineHeight - 24;\n\tconst signatureY = Math.max(doc.y + 24, Math.min(signaturePreferred, pageBottom - LAYOUT.lineHeight));\n\n\t/**\n\t * Draws one signature line: the label followed by the rule to sign on.\n\t *\n\t * @param label - text before the line\n\t * @param x - left edge of the block\n\t */\n\tconst drawSignature = (label: string, x: number): void => {\n\t\tdoc.font(fonts.regular).fontSize(LAYOUT.fontSize).fillColor(\"#000000\");\n\t\tconst written = `${label}:`;\n\t\t// without a width limit the label is never broken \u2014 the block is wide enough for all eleven languages\n\t\tdoc.text(written, x, signatureY, { lineBreak: false });\n\t\tconst start = Math.min(x + doc.widthOfString(written) + 6, x + signatureWidth - 24);\n\t\tdoc.moveTo(start, signatureY + LAYOUT.fontSize + 2)\n\t\t\t.lineTo(x + signatureWidth, signatureY + LAYOUT.fontSize + 2)\n\t\t\t.lineWidth(0.5)\n\t\t\t.strokeColor(\"#000000\")\n\t\t\t.stroke();\n\t\tdoc.y = signatureY + LAYOUT.lineHeight;\n\t};\n\n\tdrawSignature(labels.signatureEmployee, LAYOUT.margin);\n\tdrawSignature(labels.signatureManager, LAYOUT.margin + signatureWidth + signatureGap);\n\n\t// --- footer on every page -------------------------------------------------\n\t// the footer sits below the writing area on purpose, so the bottom margin is lifted while it is drawn: PDFKit\n\t// starts a new page when a text crosses the margin, and that page would carry nothing but the footer\n\tconst range = doc.bufferedPageRange();\n\tfor (let index = 0; index < range.count; index++) {\n\t\tdoc.switchToPage(range.start + index);\n\t\tconst bottom = doc.page.margins.bottom;\n\t\tdoc.page.margins.bottom = 0;\n\t\tdoc.font(fonts.regular)\n\t\t\t.fontSize(8)\n\t\t\t.fillColor(\"#757575\")\n\t\t\t.text(\n\t\t\t\t`${input.generator} - ${labels.title} ${input.year}-${String(input.month).padStart(2, \"0\")} - ${\n\t\t\t\t\tindex + 1\n\t\t\t\t}/${range.count}`,\n\t\t\t\tLAYOUT.margin,\n\t\t\t\tdoc.page.height - 30,\n\t\t\t\t{ width: doc.page.width - 2 * LAYOUT.margin, align: \"center\", lineBreak: false },\n\t\t\t);\n\t\tdoc.page.margins.bottom = bottom;\n\t}\n\n\tdoc.end();\n\tawait finished;\n\treturn Buffer.concat(chunks);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["PDFDocument", "_a"]
|
|
7
7
|
}
|
package/build/lib/web/api.js
CHANGED
|
@@ -394,6 +394,19 @@ function createApi(deps) {
|
|
|
394
394
|
);
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
|
+
function requireActiveAdministrator(userId, next) {
|
|
398
|
+
if (next.isActive && next.roleKeys.includes("admin")) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const remaining = users.list().some((user) => user.id !== userId && users.roles(user.id).includes("admin"));
|
|
402
|
+
if (!remaining) {
|
|
403
|
+
throw (0, import_problem.problem)(
|
|
404
|
+
409,
|
|
405
|
+
"last_administrator",
|
|
406
|
+
"the last active administrator cannot be deactivated or lose the admin role"
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
397
410
|
const route = (method, path, routeSettings, handler) => {
|
|
398
411
|
const methods = Array.isArray(method) ? method : [method];
|
|
399
412
|
for (const entry of methods) {
|
|
@@ -1702,12 +1715,13 @@ function createApi(deps) {
|
|
|
1702
1715
|
});
|
|
1703
1716
|
});
|
|
1704
1717
|
route("PATCH", "/users/:id", { permission: "user.edit", csrf: true }, (context) => {
|
|
1705
|
-
var _a2, _b2, _c2;
|
|
1718
|
+
var _a2, _b2, _c2, _d2;
|
|
1706
1719
|
if (!context.auth) {
|
|
1707
1720
|
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
1708
1721
|
}
|
|
1709
1722
|
const id = numberParam(context, "id");
|
|
1710
|
-
|
|
1723
|
+
const target = users.findById(id);
|
|
1724
|
+
if (!target) {
|
|
1711
1725
|
throw new import_errors.NotFoundError(`user ${id} not found`);
|
|
1712
1726
|
}
|
|
1713
1727
|
const body = context.jsonBody();
|
|
@@ -1770,19 +1784,25 @@ function createApi(deps) {
|
|
|
1770
1784
|
now: now()
|
|
1771
1785
|
});
|
|
1772
1786
|
}
|
|
1787
|
+
if (patch.isActive === false || roleKeys !== null) {
|
|
1788
|
+
requireActiveAdministrator(id, {
|
|
1789
|
+
isActive: (_b2 = patch.isActive) != null ? _b2 : target.isActive,
|
|
1790
|
+
roleKeys: roleKeys != null ? roleKeys : users.roles(id)
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1773
1793
|
const updated = users.update({
|
|
1774
1794
|
id,
|
|
1775
1795
|
patch,
|
|
1776
1796
|
reason: optionalString(body, "reason"),
|
|
1777
1797
|
actorId: context.auth.user.id,
|
|
1778
|
-
actorIp: (
|
|
1798
|
+
actorIp: (_c2 = context.request.remoteAddress) != null ? _c2 : null,
|
|
1779
1799
|
now: now()
|
|
1780
1800
|
});
|
|
1781
1801
|
const roles = roleKeys ? users.setRoles({
|
|
1782
1802
|
userId: id,
|
|
1783
1803
|
roleKeys,
|
|
1784
1804
|
actorId: context.auth.user.id,
|
|
1785
|
-
actorIp: (
|
|
1805
|
+
actorIp: (_d2 = context.request.remoteAddress) != null ? _d2 : null,
|
|
1786
1806
|
now: now()
|
|
1787
1807
|
}) : users.roles(id);
|
|
1788
1808
|
return (0, import_router.json)(200, { user: { ...publicUser(updated), roles } });
|