@patimweb/pi-zep 1.0.2 → 1.1.1

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 CHANGED
@@ -54,6 +54,43 @@ list is rendered server-side, so it only contains the Vorgänge of the project
54
54
  the page was rendered for. Posting `formrefresh=true` returns the new
55
55
  `<select id="vorgangId">` without saving anything.
56
56
 
57
+ ### Capacity planning ("Einplanung")
58
+
59
+ Same `ajax.php`, different page context. The plan page has **no table** — the
60
+ numbers live in an ApexCharts options object inside the snippet:
61
+
62
+ ```js
63
+ var optionschart123 = {"series":[
64
+ {"name":"Verfügbarkeit [108,80 h]","type":"area","data":[6.4,6.4,0, …]},
65
+ {"name":"K-10000-00003 (…) [54,40 h]","type":"bar","data":[3.2,3.2,0, …]},
66
+ {"name":"gesamt [82,40 h]","type":"line","data":[4.8,4.8,0, …]}],
67
+ "xaxis":{"categories":["Heute","Morgen","19.09.", …]},
68
+ "title":{"text":"<employee>"},"subtitle":{"text":"17.09.2026-17.10.2026"}}
69
+ ```
70
+
71
+ * Read: `POST ajax.php?…&pageContextId=Einplanung&mgr=MeineEinplanungMgr&mgrId=0&action=filter`
72
+ with the filter form (`von`, `bis`, `von_bisquicklinkdata`, `projektEinplanung`,
73
+ `gestapelt`, `ff_projektprojektTyp`, `scale`, `Ausführen`).
74
+ * The object is JavaScript, not JSON: axis locales reference a page global
75
+ (`"locales":[apex_lang_de]`) and axis/tooltip formatters are real function
76
+ expressions. Both must be neutralised before `JSON.parse` — `extractChartOptions()`
77
+ replaces function bodies and bare identifiers with `null`.
78
+ * Series kinds are detected on the name prefix (`Verfügbarkeit`, `gesamt`, else
79
+ project) and only fall back to the chart type. Project totals are read from the
80
+ `[54,40 h]` suffix in the series name; the per-day values come from the data array.
81
+ * Write: `POST …&mgr=MeineEinplanungMgr&mgrId=0_0_0_1&action=edit&refresh=1` with
82
+ `edited=[{p:<projektId>,d:<YYYY-MM-DD>,e:<wert|null>,a:'h'|'%',b:<bemerkung>}]`
83
+ plus `selected`, `scrollLeft` and `einplanungAuchAnNichtArbeitstagen`. Only the
84
+ cells the grid marked as *edited* are sent, so a save **merges** into the
85
+ existing plan instead of replacing it; `e:null` clears a cell.
86
+ * A plan cell is either hours (`a:'h'`) or a percentage of that day's
87
+ availability (`a:'%'`) — 25 % of a 6,40 h day is 1,60 h, which is how a
88
+ percentage shows up in the chart.
89
+ * Grid markup: `<td class="pep pepdata at pm <date>" data-datum data-einplanung
90
+ data-einplanungart data-bemerkung data-stundensatz>`. Rows are the projects
91
+ (`tr[data-projektid]`), the grid is keyboard driven (digits, `%` suffix,
92
+ Enter = save, Delete = clear).
93
+
57
94
  ### Response shapes (verified against the live server)
58
95
 
59
96
  | Outcome | `error` | `javascript` | content |
@@ -87,7 +124,9 @@ Other message texts observed:
87
124
  * The save response ships a *calendar* snippet targeting `managerdiv_ProjektzeitMgr`, which must not be mistaken for the week table.
88
125
  * The ISO date of a day row is in `id="day_YYYY-MM-DD"` (fallback: the `openMahlzeiten(this, 'YYYY-MM-DD')` onclick, then the weekday + `DD.MM.` label).
89
126
  * A day row's label is split across `<div>Do</div><div>17.09.</div>`, so plain `innerText` yields `Do17.09.` while tag-stripping yields `Do 17.09.` — parse the latter.
90
- * Booking rows have 10 cells (`action, von, bis, Dauer, action, Projekt, Vorgang, Tät., fakturierbar, Bemerkung`); the `class="luecke"` spacer rows in between have 1 and must be skipped; the sum row has 3.
127
+ * The **first booking of a day is rendered inside the day row itself** — its label cell carries `rowspan`, so that row holds 12–13 cells. Further bookings of the same day follow as standalone rows, separated by a `class="luecke"` spacer (1 cell). A parser that only knows standalone rows silently drops the day's first booking and attaches the remaining ones to the *previous* day.
128
+ * Booking columns are therefore read relative to the `von` cell: `von, bis, Dauer, ·, Projekt, Vorgang, Tät., fakturierbar, Bemerkung[, Ort]`.
129
+ * The optional last cell is the `Ort` (place of work). It carries no `data-columnid` of its own and ZEP renders it only when there is something to report; a trailing `*` marks a deviation from the default, and a missing cell means `NULL` (Erste Tätigkeitsstätte).
91
130
 
92
131
  ## Tools
93
132
 
@@ -101,6 +140,8 @@ Other message texts observed:
101
140
  | `zep_week` | Week overview: bookings with row id, times, duration, sum, under-booked days |
102
141
  | `zep_book` | Create bookings — one block or an `entries` array; `dryRun` supported |
103
142
  | `zep_delete` | Delete a booking by row id |
143
+ | `zep_plan` | Capacity planning (Einplanung): planned hours per day and project, availability, utilization |
144
+ | `zep_plan_set` | Write into the Einplanung matrix: hours or percent per project and day, or clear a cell |
104
145
 
105
146
  ## Usage
106
147
 
@@ -138,6 +179,9 @@ form re-render for another project, a booking created and confirmed in the week
138
179
  table, then deleted and confirmed gone. A rejected booking (plan hours
139
180
  exhausted) and a rejected Tätigkeit were reproduced as well.
140
181
 
182
+ The capacity planning was exercised the same day: the Einplanung chart was read
183
+ (read path) and a matrix cell was written and read back (`zep_plan_set`).
184
+
141
185
  What is *not* covered by that test run: a successful password login, because
142
186
  that requires the account password. The endpoint, the field names and the
143
187
  failure contract (`302 → login.php?…&action=login&msg=1&noch=N`) were verified
package/index.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  * - zep_week: read the Projektzeiten week overview
15
15
  * - zep_book: create bookings (one or several blocks)
16
16
  * - zep_delete: delete a booking
17
+ * - zep_plan: read the capacity planning (Einplanung / Kapa-Planung)
18
+ * - zep_plan_set: write planned hours into the Einplanung matrix
17
19
  *
18
20
  * Design:
19
21
  * - types.ts plain data shapes
@@ -36,6 +38,8 @@ import { ZepProjectsTool } from "./src/tools/zep-projects.ts";
36
38
  import { ZepWeekTool } from "./src/tools/zep-week.ts";
37
39
  import { ZepBookTool } from "./src/tools/zep-book.ts";
38
40
  import { ZepDeleteTool } from "./src/tools/zep-delete.ts";
41
+ import { ZepPlanTool } from "./src/tools/zep-plan.ts";
42
+ import { ZepPlanSetTool } from "./src/tools/zep-plan-set.ts";
39
43
 
40
44
  export default function (pi: ExtensionAPI) {
41
45
  loadConfig();
@@ -49,4 +53,6 @@ export default function (pi: ExtensionAPI) {
49
53
  pi.registerTool(ZepWeekTool);
50
54
  pi.registerTool(ZepBookTool);
51
55
  pi.registerTool(ZepDeleteTool);
56
+ pi.registerTool(ZepPlanTool);
57
+ pi.registerTool(ZepPlanSetTool);
52
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-zep",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "ZEP time-tracking for pi coding agent — book project times (Projektzeiten) on zep-online.de over plain HTTP, without the paid ZEP API",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -15,7 +15,7 @@
15
15
  "license": "MIT",
16
16
  "type": "module",
17
17
  "engines": {
18
- "node": ">=26.0.0"
18
+ "node": ">=22.18.0"
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  * 3. The token rotates: every response hands out the next one.
20
20
  */
21
21
 
22
- import type { LoginResult, ZepAccount, ZepBookingRequest, ZepFormData, ZepSaveResult, ZepSession, ZepWeek } from "../types.ts";
22
+ import type { LoginResult, ZepAccount, ZepBookingRequest, ZepFormData, ZepPlan, ZepPlanRequest, ZepPlanSaveRequest, ZepSaveResult, ZepSession, ZepWeek } from "../types.ts";
23
23
  import {
24
24
  detectSessionLoss,
25
25
  durationBetween,
@@ -28,6 +28,7 @@ import {
28
28
  extractRequestToken,
29
29
  extractSnippetAlert,
30
30
  parseAjaxEnvelope,
31
+ parsePlan,
31
32
  parseSelect,
32
33
  parseWeek,
33
34
  pickWeekTableSnippet,
@@ -42,6 +43,22 @@ const SAVE_MGR_ID = "3";
42
43
  /** `mgrId` of the read-only Projektzeiten table. */
43
44
  const TABLE_MGR_ID = "0";
44
45
 
46
+ /** The Projektzeiten page (booking form + week table). */
47
+ const TIME_PAGE = { menu: "ProjektzeitVerwaltungMgr", pageContextId: "Projektzeiten", mgr: "ProjektzeitMgr" };
48
+ /**
49
+ * The Einplanung page (capacity planning). It is read through its filter form:
50
+ * `action=filter` answers with the rendered chart, whose options object holds
51
+ * the planned hours per day and project.
52
+ */
53
+ const PLAN_PAGE = { menu: "MeineEinplanungVerwaltungMgr", pageContextId: "Einplanung", mgr: "MeineEinplanungMgr" };
54
+ const PLAN_MGR_ID = "0";
55
+ /**
56
+ * `mgrId` of the Einplanung matrix popup. Saving uses `action=edit&refresh=1`
57
+ * and the same mgrId - the grid hands over only the cells it marked `edited`,
58
+ * so a save merges into the existing plan instead of replacing it.
59
+ */
60
+ const PLAN_SAVE_MGR_ID = "0_0_0_1";
61
+
45
62
  export class ZepError extends Error {
46
63
  readonly attemptsLeft?: number;
47
64
  constructor(message: string, attemptsLeft?: number) {
@@ -308,14 +325,15 @@ export class ZepClient {
308
325
  action: string,
309
326
  mgrId: string,
310
327
  extra: Record<string, string> = {},
328
+ page: { pageContextId: string; mgr: string } = TIME_PAGE,
311
329
  ): { path: string; query: Record<string, string> } {
312
330
  if (!this.clientsessid) throw new ZepError("Not logged in.");
313
331
  return {
314
332
  path: "/view/ajax.php",
315
333
  query: {
316
334
  CLIENTSESSID: this.clientsessid,
317
- pageContextId: "Projektzeiten",
318
- mgr: "ProjektzeitMgr",
335
+ pageContextId: page.pageContextId,
336
+ mgr: page.mgr,
319
337
  mgrId,
320
338
  action,
321
339
  ...extra,
@@ -330,14 +348,19 @@ export class ZepClient {
330
348
  private async ajax(
331
349
  action: string,
332
350
  mgrId: string,
333
- options: { method?: "GET" | "POST"; extra?: Record<string, string>; form?: Record<string, string> } = {},
351
+ options: {
352
+ method?: "GET" | "POST";
353
+ extra?: Record<string, string>;
354
+ form?: Record<string, string | ReadonlyArray<string>>;
355
+ page?: { pageContextId: string; mgr: string };
356
+ } = {},
334
357
  ) {
335
358
  await this.ensureLogin();
336
359
  // A request without a token is answered with a session logout, so never
337
360
  // send one: fetch the page first when we do not have a fresh token yet.
338
361
  if (!this.requesttoken) await this.fetchPage();
339
362
 
340
- const { path, query } = this.ajaxUrl(action, mgrId, options.extra);
363
+ const { path, query } = this.ajaxUrl(action, mgrId, options.extra, options.page);
341
364
  const method = options.method ?? "GET";
342
365
 
343
366
  const headers: Record<string, string> = {
@@ -348,9 +371,13 @@ export class ZepClient {
348
371
  let body: string | undefined;
349
372
  if (method === "POST") {
350
373
  headers["content-type"] = "application/x-www-form-urlencoded; charset=UTF-8";
351
- const form = { ...(options.form ?? {}) };
374
+ const form: Record<string, string | ReadonlyArray<string>> = { ...(options.form ?? {}) };
352
375
  if (this.requesttoken) form.requesttoken = this.requesttoken;
353
- body = new URLSearchParams(form).toString();
376
+ const encoded = new URLSearchParams();
377
+ for (const [key, value] of Object.entries(form)) {
378
+ for (const item of Array.isArray(value) ? value : [value]) encoded.append(key, item);
379
+ }
380
+ body = encoded.toString();
354
381
  }
355
382
 
356
383
  const res = await this.raw(path, { method, headers, body, query });
@@ -374,14 +401,16 @@ export class ZepClient {
374
401
  return { form: parseFormData(html), token: this.requesttoken };
375
402
  }
376
403
 
377
- /** Load the Projektzeiten page and pick up its requesttoken. */
378
- private async fetchPage(): Promise<string> {
404
+ /** Load a ZEP page and pick up its requesttoken. */
405
+ private async fetchPage(
406
+ page: { menu: string; action?: string } = { menu: TIME_PAGE.menu, action: "save" },
407
+ ): Promise<string> {
379
408
  if (!this.clientsessid) throw new ZepError("Not logged in.");
380
409
  const res = await this.raw("/view/index.php", {
381
410
  query: {
382
411
  CLIENTSESSID: this.clientsessid,
383
- menu: "ProjektzeitVerwaltungMgr",
384
- action: "save",
412
+ menu: page.menu,
413
+ ...(page.action ? { action: page.action } : {}),
385
414
  },
386
415
  });
387
416
  const html = await res.text();
@@ -396,10 +425,12 @@ export class ZepClient {
396
425
  * Like `fetchPage`, but recovers once from a stale session by logging in
397
426
  * again. Used by the read paths.
398
427
  */
399
- private async getPageHtml(): Promise<string> {
428
+ private async getPageHtml(
429
+ page: { menu: string; action?: string } = { menu: TIME_PAGE.menu, action: "save" },
430
+ ): Promise<string> {
400
431
  await this.ensureLogin();
401
432
  try {
402
- return await this.fetchPage();
433
+ return await this.fetchPage(page);
403
434
  } catch {
404
435
  this.clientsessid = null;
405
436
  this.cookies.clear();
@@ -407,7 +438,7 @@ export class ZepClient {
407
438
  if (!result.ok) {
408
439
  throw new ZepError(result.message ?? "Login failed", result.attemptsLeft);
409
440
  }
410
- return this.fetchPage();
441
+ return this.fetchPage(page);
411
442
  }
412
443
  }
413
444
 
@@ -434,6 +465,85 @@ export class ZepClient {
434
465
  return parseWeek(tableHtml, kwDate);
435
466
  }
436
467
 
468
+ /**
469
+ * Read the capacity planning (Einplanung) chart for `from`..`to`.
470
+ *
471
+ * The page has no table: the numbers live in the ApexCharts options object
472
+ * of the chart snippet, which `parsePlan` understands.
473
+ */
474
+ async plan(request: ZepPlanRequest): Promise<ZepPlan> {
475
+ // The Einplanung page carries its own filter form and requesttoken.
476
+ await this.getPageHtml({ menu: PLAN_PAGE.menu });
477
+
478
+ const form: Record<string, string | ReadonlyArray<string>> = {
479
+ favorit: "NULL",
480
+ von_bis: "",
481
+ von_bisquicklinkdata: `${request.from}::${request.to}:`,
482
+ von: request.from,
483
+ bis: request.to,
484
+ projektEinplanung: "1",
485
+ gestapelt: "1",
486
+ ff_projektprojektTyp: request.projektTyp ?? "alle",
487
+ ff_projektstatus: "NULL",
488
+ ff_projektprojektschlagworteUndOder: "",
489
+ scale: "0",
490
+ "Ausführen": "Ausführen",
491
+ };
492
+ if (request.projektIds?.length) form["ff_projekt[]"] = request.projektIds;
493
+
494
+ const envelope = await this.ajax("filter", PLAN_MGR_ID, {
495
+ method: "POST",
496
+ form,
497
+ page: PLAN_PAGE,
498
+ });
499
+
500
+ const html = envelope.snippets.map((snippet) => snippet.data).join("\n");
501
+ const plan = parsePlan(html, request.from, request.to);
502
+ if (!plan) {
503
+ throw new ZepError(
504
+ "ZEP returned no readable Einplanung chart. Check the date range (from/to) and that the account has an Einplanung.",
505
+ );
506
+ }
507
+ return plan;
508
+ }
509
+
510
+ /**
511
+ * Write planned hours/percent into the Einplanung matrix.
512
+ *
513
+ * Wire contract taken from the grid's own save handler: the body carries
514
+ * `edited` as a JSON array of `{p: projektId, d: datum, e: wert, a: 'h'|'%',
515
+ * b: bemerkung}` entries - `e: null` clears the cell - plus the selection,
516
+ * the scroll position and the non-working-day switch.
517
+ */
518
+ async savePlan(request: ZepPlanSaveRequest): Promise<ZepSaveResult> {
519
+ // The Einplanung page carries the requesttoken this action needs.
520
+ await this.getPageHtml({ menu: PLAN_PAGE.menu });
521
+
522
+ const edited = request.entries.map((entry) => ({
523
+ p: entry.projektId,
524
+ d: entry.date,
525
+ e: entry.value,
526
+ // Clearing sends a null unit, exactly like the grid does for a cell it
527
+ // emptied with the Delete key.
528
+ a: entry.value === null ? null : entry.unit,
529
+ b: entry.comment ?? "",
530
+ }));
531
+
532
+ const envelope = await this.ajax("edit", PLAN_SAVE_MGR_ID, {
533
+ method: "POST",
534
+ page: PLAN_PAGE,
535
+ extra: { refresh: "1" },
536
+ form: {
537
+ edited: JSON.stringify(edited),
538
+ selected: JSON.stringify([]),
539
+ scrollLeft: "0",
540
+ einplanungAuchAnNichtArbeitstagen: request.auchAnNichtArbeitstagen ? "1" : "0",
541
+ },
542
+ });
543
+
544
+ return this.verdict(envelope, `${edited.length} Einplanung(en) gespeichert.`);
545
+ }
546
+
437
547
  /**
438
548
  * Turn an ajax envelope into a success/failure verdict.
439
549
  *
@@ -8,7 +8,7 @@
8
8
  * tolerant regex parsers rather than a full HTML parser.
9
9
  */
10
10
 
11
- import type { ZepBooking, ZepOption, ZepWeek, ZepWeekDay } from "../types.ts";
11
+ import type { ZepBooking, ZepOption, ZepPlan, ZepPlanDay, ZepPlanSlice, ZepWeek, ZepWeekDay } from "../types.ts";
12
12
 
13
13
  /** Decode the handful of entities ZEP actually uses in labels. */
14
14
  export function decodeEntities(input: string): string {
@@ -466,3 +466,286 @@ export function durationBetween(from: string, to: string): string | null {
466
466
  const min = diff % 60;
467
467
  return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
468
468
  }
469
+
470
+ // --------------------------------------------- capacity planning ("Einplanung")
471
+
472
+ export interface ZepChartSeries {
473
+ readonly name: string;
474
+ readonly type?: string;
475
+ readonly data: ReadonlyArray<number>;
476
+ }
477
+
478
+ /** The slice of ZEP's ApexCharts options object that carries the plan data. */
479
+ export interface ZepChartOptions {
480
+ readonly series: ReadonlyArray<ZepChartSeries>;
481
+ readonly categories: ReadonlyArray<string>;
482
+ readonly title: string | null;
483
+ readonly subtitle: string | null;
484
+ }
485
+
486
+ interface RawChartOptions {
487
+ series?: unknown;
488
+ xaxis?: { categories?: unknown };
489
+ title?: { text?: unknown };
490
+ subtitle?: { text?: unknown };
491
+ }
492
+
493
+ /** Trailing `[16,00 h]` ZEP appends to every series name. */
494
+ const SERIES_HOURS_RE = /\s*\[([\d.,]+)\s*h\]\s*$/;
495
+
496
+ function round2(value: number): number {
497
+ return Math.round(value * 100) / 100;
498
+ }
499
+
500
+ /**
501
+ * Slice a balanced `{...}` or `[...]` block starting at `start`.
502
+ *
503
+ * String-aware on purpose: series names are project labels like
504
+ * `K-10000-00001 (Contoso) [16,00 h]`, so brackets inside string literals
505
+ * must not be counted as nesting.
506
+ */
507
+ function sliceBalanced(text: string, start: number): string | null {
508
+ const open = text[start];
509
+ if (open !== "{" && open !== "[") return null;
510
+ const close = open === "{" ? "}" : "]";
511
+
512
+ let depth = 0;
513
+ let inString = false;
514
+ let escaped = false;
515
+
516
+ for (let i = start; i < text.length; i += 1) {
517
+ const char = text[i]!;
518
+ if (inString) {
519
+ if (escaped) escaped = false;
520
+ else if (char === "\\") escaped = true;
521
+ else if (char === '"') inString = false;
522
+ continue;
523
+ }
524
+ if (char === '"') {
525
+ inString = true;
526
+ continue;
527
+ }
528
+ if (char === open) depth += 1;
529
+ else if (char === close) {
530
+ depth -= 1;
531
+ if (depth === 0) return text.slice(start, i + 1);
532
+ }
533
+ }
534
+ return null;
535
+ }
536
+
537
+ /**
538
+ * Turn ZEP's options object into parseable JSON.
539
+ *
540
+ * It is JavaScript, not JSON: the axis label and tooltip formatters are real
541
+ * function expressions (`"formatter":function(val, index) { ... }`). Their
542
+ * values carry no plan data, so each one is replaced by `null`.
543
+ */
544
+ function stripJsFunctions(text: string): string {
545
+ let result = "";
546
+ let index = 0;
547
+
548
+ while (index < text.length) {
549
+ const found = text.indexOf("function", index);
550
+ if (found === -1) {
551
+ result += text.slice(index);
552
+ break;
553
+ }
554
+
555
+ // Only treat it as code in value position; a project label could contain
556
+ // the word "function" as text.
557
+ const before = text.slice(0, found).replace(/\s+$/, "").slice(-1);
558
+ if (before !== ":" && before !== "," && before !== "[" && before !== "(") {
559
+ result += text.slice(index, found + "function".length);
560
+ index = found + "function".length;
561
+ continue;
562
+ }
563
+
564
+ const bodyStart = text.indexOf("{", found + "function".length);
565
+ const body = bodyStart === -1 ? null : sliceBalanced(text, bodyStart);
566
+ // keep everything before the keyword - it is part of the object
567
+ result += text.slice(index, found) + "null";
568
+ if (!body) break;
569
+ index = bodyStart + body.length;
570
+ }
571
+ return result;
572
+ }
573
+
574
+ /**
575
+ * Replace bare JavaScript identifiers that are not JSON keywords with `null`.
576
+ *
577
+ * ZEP's object is JavaScript, not JSON: axis locales arrive as a reference to
578
+ * the page's global (`"locales":[apex_lang_de]`), which JSON.parse rejects.
579
+ */
580
+ function neutraliseJsValues(jsonish: string): string {
581
+ let result = "";
582
+ let inString = false;
583
+ let escaped = false;
584
+ let index = 0;
585
+
586
+ while (index < jsonish.length) {
587
+ const char = jsonish[index]!;
588
+ if (inString) {
589
+ result += char;
590
+ if (escaped) escaped = false;
591
+ else if (char === "\\") escaped = true;
592
+ else if (char === '"') inString = false;
593
+ index += 1;
594
+ continue;
595
+ }
596
+ if (char === '"') {
597
+ inString = true;
598
+ result += char;
599
+ index += 1;
600
+ continue;
601
+ }
602
+ if (/[A-Za-z_$]/.test(char)) {
603
+ let end = index;
604
+ while (end < jsonish.length && /[A-Za-z0-9_$.]/.test(jsonish[end]!)) end += 1;
605
+ const token = jsonish.slice(index, end);
606
+ result += token === "true" || token === "false" || token === "null" ? token : "null";
607
+ index = end;
608
+ continue;
609
+ }
610
+ result += char;
611
+ index += 1;
612
+ }
613
+ return result;
614
+ }
615
+
616
+ /** Read the ApexCharts options object ZEP embeds next to a chart container. */
617
+ export function extractChartOptions(html: string): ZepChartOptions | null {
618
+ const marker = /var\s+options\w*\s*=\s*/.exec(html);
619
+ if (!marker) return null;
620
+
621
+ const start = html.indexOf("{", marker.index + marker[0].length);
622
+ if (start === -1) return null;
623
+
624
+ const raw = sliceBalanced(html, start);
625
+ if (!raw) return null;
626
+
627
+ let parsed: RawChartOptions;
628
+ try {
629
+ parsed = JSON.parse(neutraliseJsValues(stripJsFunctions(raw))) as RawChartOptions;
630
+ } catch {
631
+ return null;
632
+ }
633
+
634
+ const rawSeries = Array.isArray(parsed.series) ? parsed.series : [];
635
+ const series: ZepChartSeries[] = [];
636
+ for (const entry of rawSeries) {
637
+ if (!entry || typeof entry !== "object") continue;
638
+ const shaped = entry as { name?: unknown; type?: unknown; data?: unknown };
639
+ if (typeof shaped.name !== "string" || shaped.name === "") continue;
640
+ series.push({
641
+ name: shaped.name,
642
+ type: typeof shaped.type === "string" ? shaped.type : undefined,
643
+ data: Array.isArray(shaped.data)
644
+ ? shaped.data.filter((value): value is number => typeof value === "number")
645
+ : [],
646
+ });
647
+ }
648
+
649
+ const categories = Array.isArray(parsed.xaxis?.categories)
650
+ ? parsed.xaxis.categories.filter((value): value is string => typeof value === "string")
651
+ : [];
652
+
653
+ return {
654
+ series,
655
+ categories,
656
+ title: typeof parsed.title?.text === "string" ? parsed.title.text : null,
657
+ subtitle: typeof parsed.subtitle?.text === "string" ? parsed.subtitle.text : null,
658
+ };
659
+ }
660
+
661
+ /** `[16,00 h]` -> 16, `[108,80 h]` -> 108.8 */
662
+ function hoursFromSeriesName(name: string): number | null {
663
+ const match = SERIES_HOURS_RE.exec(name);
664
+ if (!match) return null;
665
+ const value = Number((match[1] ?? "").replace(/\./g, "").replace(",", "."));
666
+ return Number.isFinite(value) ? value : null;
667
+ }
668
+
669
+ function addDays(iso: string, days: number): string {
670
+ const date = new Date(`${iso}T00:00:00Z`);
671
+ date.setUTCDate(date.getUTCDate() + days);
672
+ return date.toISOString().slice(0, 10);
673
+ }
674
+
675
+ /**
676
+ * Parse the Einplanung chart into days, capacity and planned hours.
677
+ *
678
+ * ZEP renders three kinds of series: `Verfügbarkeit [108,80 h]` (the person's
679
+ * capacity per day), one `bar` series per project and `gesamt [82,40 h]` (the
680
+ * daily total). The kind is detected on the name prefix and only falls back to
681
+ * the chart type.
682
+ */
683
+ export function parsePlan(html: string, from: string, to: string): ZepPlan | null {
684
+ const options = extractChartOptions(html);
685
+ if (!options || options.series.length === 0) return null;
686
+
687
+ let availability: ReadonlyArray<number> = [];
688
+ let totals: ReadonlyArray<number> = [];
689
+ const projectSeries: Array<{ name: string; data: ReadonlyArray<number>; stated: number | null }> = [];
690
+
691
+ for (const series of options.series) {
692
+ const label = series.name.replace(SERIES_HOURS_RE, "").trim();
693
+ if (/^verf/i.test(label) || series.type === "area") {
694
+ availability = series.data;
695
+ } else if (/^gesamt/i.test(label) || series.type === "line") {
696
+ totals = series.data;
697
+ } else {
698
+ projectSeries.push({ name: label, data: series.data, stated: hoursFromSeriesName(series.name) });
699
+ }
700
+ }
701
+
702
+ const columnCount = Math.max(
703
+ options.categories.length,
704
+ ...options.series.map((series) => series.data.length),
705
+ 0,
706
+ );
707
+ if (columnCount === 0) return null;
708
+
709
+ const days: ZepPlanDay[] = [];
710
+ const summed = new Map<string, number>();
711
+
712
+ for (let index = 0; index < columnCount; index += 1) {
713
+ const slices: ZepPlanSlice[] = [];
714
+ for (const series of projectSeries) {
715
+ const hours = series.data[index] ?? 0;
716
+ if (hours <= 0) continue;
717
+ slices.push({ project: series.name, hours: round2(hours) });
718
+ summed.set(series.name, round2((summed.get(series.name) ?? 0) + hours));
719
+ }
720
+ slices.sort((a, b) => b.hours - a.hours);
721
+
722
+ const available = availability[index];
723
+ const total = totals[index] ?? slices.reduce((sum, slice) => sum + slice.hours, 0);
724
+
725
+ days.push({
726
+ label: options.categories[index] ?? "",
727
+ date: addDays(from, index),
728
+ available: available === undefined ? null : round2(available),
729
+ planned: round2(total ?? 0),
730
+ slices,
731
+ });
732
+ }
733
+
734
+ return {
735
+ title: options.title,
736
+ from,
737
+ to,
738
+ range: options.subtitle,
739
+ capacity: round2(days.reduce((sum, day) => sum + (day.available ?? 0), 0)),
740
+ planned: round2(days.reduce((sum, day) => sum + day.planned, 0)),
741
+ // ZEP states each project's range total in its series name ([16,00 h]);
742
+ // prefer that number, fall back to our own sum of the days.
743
+ projects: projectSeries
744
+ .map((series) => ({
745
+ project: series.name,
746
+ hours: series.stated ?? summed.get(series.name) ?? 0,
747
+ }))
748
+ .sort((a, b) => b.hours - a.hours),
749
+ days,
750
+ };
751
+ }
@@ -2,7 +2,7 @@
2
2
  * Pure functions that turn ZEP domain data into text for the model/user.
3
3
  */
4
4
 
5
- import type { ZepFormData, ZepSaveResult, ZepWeek } from "../types.ts";
5
+ import type { ZepFormData, ZepPlan, ZepPlanEntry, ZepSaveResult, ZepWeek } from "../types.ts";
6
6
 
7
7
  function pad(value: string, width: number): string {
8
8
  return value.length >= width ? value : value + " ".repeat(width - value.length);
@@ -91,3 +91,73 @@ export function formatMoneyTable(headers: ReadonlyArray<string>, rows: ReadonlyA
91
91
  cells.map((c, i) => pad(c ?? "", widths[i] ?? 0)).join(" ").trimEnd();
92
92
  return [line(headers), widths.map((w) => "-".repeat(w)).join(" "), ...rows.map(line)].join("\n");
93
93
  }
94
+
95
+ // ------------------------------------------------- capacity planning (Einplanung)
96
+
97
+ /** `16` -> `16,00` (ZEP prints hours with a comma). */
98
+ function formatHours(value: number): string {
99
+ return value.toFixed(2).replace(".", ",");
100
+ }
101
+
102
+ const WEEKDAYS = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"] as const;
103
+
104
+ function dayParts(iso: string): { weekday: string; short: string } {
105
+ const date = new Date(`${iso}T00:00:00Z`);
106
+ const weekday = WEEKDAYS[date.getUTCDay()] ?? "??";
107
+ const short = `${String(date.getUTCDate()).padStart(2, "0")}.${String(date.getUTCMonth() + 1).padStart(2, "0")}.`;
108
+ return { weekday, short };
109
+ }
110
+
111
+ export function formatPlan(plan: ZepPlan): string {
112
+ const lines: string[] = [];
113
+ lines.push(`Einplanung${plan.title ? ` ${plan.title}` : ""} (${plan.range ?? `${plan.from} - ${plan.to}`})`);
114
+
115
+ const utilization = plan.capacity > 0 ? Math.round((plan.planned / plan.capacity) * 100) : null;
116
+ const overBooked = plan.days.filter(
117
+ (day) => day.available !== null && day.planned > day.available + 0.001,
118
+ ).length;
119
+
120
+ lines.push(
121
+ `Kapazität ${formatHours(plan.capacity)} h | geplant ${formatHours(plan.planned)} h` +
122
+ `${utilization === null ? "" : ` | Auslastung ${utilization} %`}` +
123
+ `${overBooked > 0 ? ` | ${overBooked} Tag(e) überbucht` : ""}`,
124
+ );
125
+ lines.push("");
126
+
127
+ const plannedDays = plan.days.filter((day) => day.planned > 0);
128
+ if (plannedDays.length === 0) {
129
+ lines.push("(keine Einplanung im Zeitraum)");
130
+ }
131
+ for (const day of plannedDays) {
132
+ const { weekday, short } = dayParts(day.date);
133
+ const slices = day.slices
134
+ .map((slice) => `${slice.project} ${formatHours(slice.hours)}`)
135
+ .join(" | ");
136
+ lines.push(
137
+ `${weekday} ${short} ${formatHours(day.planned)}` +
138
+ `${day.available === null ? "" : ` / ${formatHours(day.available)}`} h ${slices}`,
139
+ );
140
+ }
141
+
142
+ if (plan.projects.length > 0) {
143
+ lines.push("", "Planstunden je Projekt:");
144
+ for (const project of plan.projects) {
145
+ lines.push(` ${formatHours(project.hours).padStart(6)} h ${project.project}`);
146
+ }
147
+ }
148
+
149
+ return lines.join("\n").trimEnd();
150
+ }
151
+
152
+ /** One line per changed cell, used by zep_plan_set. */
153
+ export function formatPlanEntries(entries: ReadonlyArray<ZepPlanEntry>): string {
154
+ return entries
155
+ .map((entry) => {
156
+ const value =
157
+ entry.value === null ? "geleert" : `${formatHours(entry.value)} ${entry.unit === "%" ? "%" : "h"}`;
158
+ return ` ${entry.date} Projekt ${entry.projektId} ${value}${
159
+ entry.comment ? ` "${entry.comment}"` : ""
160
+ }`;
161
+ })
162
+ .join("\n");
163
+ }
@@ -42,3 +42,26 @@ export function toHhMm(value: string): string {
42
42
  if (hourOnly) return `${hourOnly[1]!.padStart(2, "0")}:00`;
43
43
  throw new Error(`Unparseable time: "${value}" (use HH:MM)`);
44
44
  }
45
+
46
+ /**
47
+ * Match an id exactly first, then an exact label, then a label substring.
48
+ * Used by every tool that accepts "id or part of the label".
49
+ */
50
+ export function matchOption<T extends { id: string; label: string }>(
51
+ options: ReadonlyArray<T>,
52
+ reference: string,
53
+ ): T | undefined {
54
+ const needle = reference.trim().toLowerCase();
55
+ return (
56
+ options.find((option) => option.id === reference.trim()) ??
57
+ options.find((option) => option.label.toLowerCase() === needle) ??
58
+ options.find((option) => option.label.toLowerCase().includes(needle))
59
+ );
60
+ }
61
+
62
+ /** Shift an ISO date by whole days. */
63
+ export function addDays(iso: string, days: number): string {
64
+ const date = new Date(`${iso}T00:00:00Z`);
65
+ date.setUTCDate(date.getUTCDate() + days);
66
+ return date.toISOString().slice(0, 10);
67
+ }
@@ -12,7 +12,7 @@ import { durationBetween } from "../clients/zep-parse.ts";
12
12
  import { ZepError } from "../clients/zep-client.ts";
13
13
  import { updateProfile } from "../config.ts";
14
14
  import type { ZepBookingRequest, ZepFormData, ZepOption } from "../types.ts";
15
- import { errorResult, textResult, toHhMm, toIsoDate, type ToolResult } from "./shared.ts";
15
+ import { errorResult, matchOption, textResult, toHhMm, toIsoDate, type ToolResult } from "./shared.ts";
16
16
 
17
17
  interface EntryParam {
18
18
  von: string;
@@ -309,19 +309,6 @@ export const ZepBookTool = {
309
309
  },
310
310
  };
311
311
 
312
- /** Match an id exactly first, then an exact label, then a label substring. */
313
- function matchOption(
314
- options: ReadonlyArray<{ id: string; label: string }>,
315
- reference: string,
316
- ): { id: string; label: string } | undefined {
317
- const needle = reference.trim().toLowerCase();
318
- return (
319
- options.find((o) => o.id === reference.trim()) ??
320
- options.find((o) => o.label.toLowerCase() === needle) ??
321
- options.find((o) => o.label.toLowerCase().includes(needle))
322
- );
323
- }
324
-
325
312
  function resolveTaetigkeit(scope: ProjectScope, reference: string | undefined): string {
326
313
  if (reference) {
327
314
  const match = matchOption(scope.taetigkeiten, reference);
@@ -0,0 +1,141 @@
1
+ /**
2
+ * zep_plan_set -- write planned hours into the ZEP capacity planning.
3
+ *
4
+ * ZEP's Einplanung matrix stores one value per project and day, either as
5
+ * hours ("h") or as a percentage of that day's availability ("%"). The grid
6
+ * posts only the cells it marked as edited, so a save merges into the existing
7
+ * plan; `value: null` clears a cell.
8
+ */
9
+
10
+ import { Type } from "typebox";
11
+
12
+ import { createClient } from "../session.ts";
13
+ import { formatPlan, formatPlanEntries } from "../formatting/formatters.ts";
14
+ import { errorResult, matchOption, textResult, toIsoDate, type ToolResult } from "./shared.ts";
15
+ import type { ZepPlanEntry } from "../types.ts";
16
+
17
+ interface CellParams {
18
+ datum?: string;
19
+ projekt?: string;
20
+ stunden?: number;
21
+ prozent?: number;
22
+ leeren?: boolean;
23
+ bemerkung?: string;
24
+ }
25
+
26
+ const CellSchema = Type.Object({
27
+ datum: Type.Optional(Type.String({ description: "Day (YYYY-MM-DD or DD.MM.YYYY). Defaults to today." })),
28
+ projekt: Type.Optional(
29
+ Type.String({ description: "Project id (e.g. '371') or part of its label." }),
30
+ ),
31
+ stunden: Type.Optional(Type.Number({ description: "Planned hours for that day." })),
32
+ prozent: Type.Optional(
33
+ Type.Number({ description: "Planned percentage of the day's availability (ZEP stores '%')." }),
34
+ ),
35
+ leeren: Type.Optional(Type.Boolean({ description: "Clear the cell instead of setting a value." })),
36
+ bemerkung: Type.Optional(Type.String({ description: "Note stored with the plan cell." })),
37
+ });
38
+
39
+ export const ZepPlanSetTool = {
40
+ name: "zep_plan_set",
41
+ label: "ZEP Einplanung setzen",
42
+ description:
43
+ "Write into the ZEP capacity planning (Einplanung / Kapa-Planung): set planned hours or a percentage for one project and day, or clear the cell. Give one cell with the top-level fields or several with 'entries'. Use dryRun to see the exact request without saving. Every save is verified by re-reading the plan.",
44
+ parameters: Type.Object({
45
+ profile: Type.Optional(
46
+ Type.String({ description: "ZEP profile to use. Defaults to the active profile." }),
47
+ ),
48
+ datum: Type.Optional(Type.String({ description: "Day for a single cell. Defaults to today." })),
49
+ projekt: Type.Optional(Type.String({ description: "Project id or part of its label." })),
50
+ stunden: Type.Optional(Type.Number({ description: "Planned hours." })),
51
+ prozent: Type.Optional(Type.Number({ description: "Planned percentage of the day's availability." })),
52
+ leeren: Type.Optional(Type.Boolean({ description: "Clear the cell instead of setting a value." })),
53
+ bemerkung: Type.Optional(Type.String({ description: "Note stored with the plan cell." })),
54
+ entries: Type.Optional(
55
+ Type.Array(CellSchema, { description: "Several cells in one call." }),
56
+ ),
57
+ dryRun: Type.Optional(
58
+ Type.Boolean({ description: "Resolve everything and show what would be sent, without saving." }),
59
+ ),
60
+ }),
61
+
62
+ async execute(
63
+ _toolCallId: string,
64
+ params: CellParams & { profile?: string; entries?: ReadonlyArray<CellParams>; dryRun?: boolean },
65
+ ): Promise<ToolResult> {
66
+ try {
67
+ const cells: CellParams[] = params.entries?.length ? [...params.entries] : [params];
68
+ if (cells.length === 0) return errorResult("No plan cell given.");
69
+
70
+ const { client, profileName } = await createClient(params.profile);
71
+ const { form } = await client.loadForm();
72
+
73
+ const entries: ZepPlanEntry[] = [];
74
+ for (const cell of cells) {
75
+ const date = toIsoDate(cell.datum ?? params.datum);
76
+ const reference = cell.projekt ?? params.projekt;
77
+ if (!reference) return errorResult("No project given: pass 'projekt' (id or label).");
78
+
79
+ const project = matchOption(form.projects, reference);
80
+ if (!project) {
81
+ return errorResult(
82
+ `No project matches "${reference}". Known projects: ${form.projects
83
+ .map((option) => `${option.id}=${option.label}`)
84
+ .join(" | ")}`,
85
+ );
86
+ }
87
+
88
+ const hours = cell.stunden;
89
+ const percent = cell.prozent;
90
+ const clear = cell.leeren ?? false;
91
+ const given = [hours !== undefined, percent !== undefined, clear].filter(Boolean).length;
92
+ if (given !== 1) {
93
+ return errorResult(
94
+ `Give exactly one of 'stunden', 'prozent' or 'leeren' for ${date} / ${project.label}.`,
95
+ );
96
+ }
97
+
98
+ const comment = cell.bemerkung ?? params.bemerkung;
99
+ entries.push({
100
+ projektId: project.id,
101
+ date,
102
+ value: clear ? null : (hours ?? percent ?? null),
103
+ unit: percent !== undefined ? "%" : "h",
104
+ ...(comment ? { comment } : {}),
105
+ });
106
+ }
107
+
108
+ const preview =
109
+ `${params.dryRun ? "DRY RUN - nothing saved.\n\n" : ""}` +
110
+ `Einplanung (${entries.length} Zelle(n)):\n${formatPlanEntries(entries)}`;
111
+
112
+ if (params.dryRun) return textResult(preview, { profile: profileName, dryRun: true });
113
+
114
+ const saved = await client.savePlan({ entries });
115
+ if (!saved.ok) {
116
+ return errorResult(`ZEP rejected the Einplanung: ${saved.error ?? "unspecified error"}`);
117
+ }
118
+
119
+ // Read back what ZEP stored for the touched days.
120
+ const dates = entries.map((entry) => entry.date).sort();
121
+ const from = dates[0]!;
122
+ const to = dates[dates.length - 1]!;
123
+ const plan = await client.plan({ from, to });
124
+
125
+ return textResult(
126
+ `${preview}\n\n${saved.message ?? "saved"}\n\nKontrolle (${from} - ${to}):\n${formatPlan(plan)}`,
127
+ {
128
+ profile: profileName,
129
+ saved: true,
130
+ entries: entries.length,
131
+ from,
132
+ to,
133
+ planned: plan.planned,
134
+ capacity: plan.capacity,
135
+ },
136
+ );
137
+ } catch (error) {
138
+ return errorResult(error instanceof Error ? error.message : String(error));
139
+ }
140
+ },
141
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * zep_plan -- read the ZEP capacity planning ("Einplanung", Kapa-Planung).
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+
7
+ import { createClient } from "../session.ts";
8
+ import { formatPlan } from "../formatting/formatters.ts";
9
+ import { addDays, errorResult, textResult, toIsoDate, type ToolResult } from "./shared.ts";
10
+
11
+ export const ZepPlanTool = {
12
+ name: "zep_plan",
13
+ label: "ZEP Einplanung",
14
+ description:
15
+ "Show the ZEP capacity planning (Einplanung / Kapa-Planung): per day the planned hours, the available hours and the per-project breakdown, plus total capacity, planned hours and utilization. Read-only. Defaults to today plus 30 days.",
16
+ parameters: Type.Object({
17
+ profile: Type.Optional(
18
+ Type.String({ description: "ZEP profile to use. Defaults to the active profile." }),
19
+ ),
20
+ von: Type.Optional(
21
+ Type.String({
22
+ description: "Start date (YYYY-MM-DD or DD.MM.YYYY). Defaults to today.",
23
+ }),
24
+ ),
25
+ bis: Type.Optional(
26
+ Type.String({
27
+ description: "End date. Defaults to 30 days after 'von' (ZEP's own default range).",
28
+ }),
29
+ ),
30
+ projektTyp: Type.Optional(
31
+ Type.String({
32
+ description: "ZEP Projekttyp filter: 'alle' (default), 'intern' or 'kunden'.",
33
+ }),
34
+ ),
35
+ projekte: Type.Optional(
36
+ Type.Array(Type.String(), {
37
+ description: "Restrict the plan to these project ids.",
38
+ }),
39
+ ),
40
+ }),
41
+
42
+ async execute(
43
+ _toolCallId: string,
44
+ params: {
45
+ profile?: string;
46
+ von?: string;
47
+ bis?: string;
48
+ projektTyp?: string;
49
+ projekte?: ReadonlyArray<string>;
50
+ },
51
+ ): Promise<ToolResult> {
52
+ try {
53
+ const from = toIsoDate(params.von);
54
+ const to = params.bis ? toIsoDate(params.bis) : addDays(from, 30);
55
+
56
+ const { client, profileName } = await createClient(params.profile);
57
+ const plan = await client.plan({
58
+ from,
59
+ to,
60
+ projektTyp: params.projektTyp,
61
+ projektIds: params.projekte,
62
+ });
63
+
64
+ return textResult(formatPlan(plan), {
65
+ profile: profileName,
66
+ from: plan.from,
67
+ to: plan.to,
68
+ capacity: plan.capacity,
69
+ planned: plan.planned,
70
+ projects: plan.projects.length,
71
+ days: plan.days.length,
72
+ });
73
+ } catch (error) {
74
+ return errorResult(error instanceof Error ? error.message : String(error));
75
+ }
76
+ },
77
+ };
package/src/types.ts CHANGED
@@ -156,3 +156,77 @@ export interface SetupParams {
156
156
  mandant?: string;
157
157
  baseUrl?: string;
158
158
  }
159
+
160
+ // ------------------------------------------------- capacity planning (Einplanung)
161
+
162
+ /** One project's planned hours on one day. */
163
+ export interface ZepPlanSlice {
164
+ readonly project: string;
165
+ readonly hours: number;
166
+ }
167
+
168
+ /** One day on the Einplanung chart. */
169
+ export interface ZepPlanDay {
170
+ /** Day label exactly as ZEP prints it: `Heute`, `Morgen` or `19.09.`. */
171
+ readonly label: string;
172
+ /** Resolved ISO date of that column. */
173
+ readonly date: string;
174
+ /** Available hours from ZEP's `Verfügbarkeit` series, null when absent. */
175
+ readonly available: number | null;
176
+ /** Planned hours from ZEP's `gesamt` series. */
177
+ readonly planned: number;
178
+ /** Planned hours per project on this day, largest first. */
179
+ readonly slices: ReadonlyArray<ZepPlanSlice>;
180
+ }
181
+
182
+ /** A project's planned hours over the whole requested range. */
183
+ export interface ZepPlanProject {
184
+ readonly project: string;
185
+ readonly hours: number;
186
+ }
187
+
188
+ /** Result of reading the Einplanung (capacity planning) chart. */
189
+ export interface ZepPlan {
190
+ /** Chart title - the employee the plan belongs to. */
191
+ readonly title: string | null;
192
+ /** Requested range, ISO dates. */
193
+ readonly from: string;
194
+ readonly to: string;
195
+ /** Range as ZEP prints it in the chart subtitle. */
196
+ readonly range: string | null;
197
+ /** Sum of the availability series. */
198
+ readonly capacity: number;
199
+ /** Sum of the `gesamt` series. */
200
+ readonly planned: number;
201
+ readonly projects: ReadonlyArray<ZepPlanProject>;
202
+ readonly days: ReadonlyArray<ZepPlanDay>;
203
+ }
204
+
205
+ export interface ZepPlanRequest {
206
+ readonly from: string;
207
+ readonly to: string;
208
+ /** `alle`, `intern` or `kunden` - ZEP's Projekttyp filter. */
209
+ readonly projektTyp?: string;
210
+ /** Project ids for ZEP's Projekt filter. */
211
+ readonly projektIds?: ReadonlyArray<string>;
212
+ }
213
+
214
+ /**
215
+ * One cell of the Einplanung matrix as ZEP stores it.
216
+ *
217
+ * ZEP keeps the plan per project and day, either as hours (`h`) or as a
218
+ * percentage of that day's availability (`%`). `value: null` clears the cell.
219
+ */
220
+ export interface ZepPlanEntry {
221
+ readonly projektId: string;
222
+ readonly date: string;
223
+ readonly value: number | null;
224
+ readonly unit: "h" | "%";
225
+ readonly comment?: string;
226
+ }
227
+
228
+ export interface ZepPlanSaveRequest {
229
+ readonly entries: ReadonlyArray<ZepPlanEntry>;
230
+ /** ZEP's "Einplanung auch an Nicht-Arbeitstagen" switch. */
231
+ readonly auchAnNichtArbeitstagen?: boolean;
232
+ }