@patimweb/pi-zep 1.0.0
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/.node-version +1 -0
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/index.ts +49 -0
- package/package.json +53 -0
- package/src/clients/zep-client.ts +587 -0
- package/src/clients/zep-parse.ts +423 -0
- package/src/config.ts +155 -0
- package/src/formatting/formatters.ts +92 -0
- package/src/session.ts +86 -0
- package/src/tools/shared.ts +44 -0
- package/src/tools/zep-book.ts +323 -0
- package/src/tools/zep-delete.ts +42 -0
- package/src/tools/zep-login.ts +75 -0
- package/src/tools/zep-profile.ts +73 -0
- package/src/tools/zep-projects.ts +78 -0
- package/src/tools/zep-setup.ts +74 -0
- package/src/tools/zep-status.ts +103 -0
- package/src/tools/zep-week.ts +45 -0
- package/src/types.ts +151 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ZEP HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: ZEP's official REST API is a paid add-on and (as of
|
|
5
|
+
* v7.13.x) cannot even create a Projektzeit booking. The web UI, however,
|
|
6
|
+
* talks to a plain PHP endpoint that can:
|
|
7
|
+
*
|
|
8
|
+
* POST {root}/view/ajax.php?CLIENTSESSID=..&pageContextId=Projektzeiten
|
|
9
|
+
* &mgr=ProjektzeitMgr&mgrId=3&action=save
|
|
10
|
+
* &JS_ENV_VAR_isFast=true&JS_ENV_VAR_locale=de&json=true&ajax=1
|
|
11
|
+
* header: x-requesttoken: <token>
|
|
12
|
+
* body: form-urlencoded booking fields + requesttoken
|
|
13
|
+
*
|
|
14
|
+
* Three things are easy to get wrong and are handled here:
|
|
15
|
+
* 1. The session is `CLIENTSESSID` (URL) *plus* a `PHPSESSID` cookie. Neither alone works.
|
|
16
|
+
* 2. `x-requesttoken` is mandatory. A missing or stale token makes the server
|
|
17
|
+
* invalidate the whole session (msg=555001 / 555003 / 555009) - it does not
|
|
18
|
+
* just reject the one request.
|
|
19
|
+
* 3. The token rotates: every response hands out the next one.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { LoginResult, ZepAccount, ZepBookingRequest, ZepFormData, ZepSaveResult, ZepSession, ZepWeek } from "../types.ts";
|
|
23
|
+
import {
|
|
24
|
+
detectSessionLoss,
|
|
25
|
+
durationBetween,
|
|
26
|
+
extractAlertMessage,
|
|
27
|
+
extractClientSessionId,
|
|
28
|
+
extractRequestToken,
|
|
29
|
+
extractSnippetAlert,
|
|
30
|
+
parseAjaxEnvelope,
|
|
31
|
+
parseSelect,
|
|
32
|
+
parseWeek,
|
|
33
|
+
pickWeekTableSnippet,
|
|
34
|
+
selectedValue,
|
|
35
|
+
} from "./zep-parse.ts";
|
|
36
|
+
|
|
37
|
+
const USER_AGENT =
|
|
38
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
|
|
39
|
+
|
|
40
|
+
/** `mgrId` of the Projektzeiten booking form on the Projektzeiten page. */
|
|
41
|
+
const SAVE_MGR_ID = "3";
|
|
42
|
+
/** `mgrId` of the read-only Projektzeiten table. */
|
|
43
|
+
const TABLE_MGR_ID = "0";
|
|
44
|
+
|
|
45
|
+
export class ZepError extends Error {
|
|
46
|
+
readonly attemptsLeft?: number;
|
|
47
|
+
constructor(message: string, attemptsLeft?: number) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ZepError";
|
|
50
|
+
this.attemptsLeft = attemptsLeft;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ZepClientOptions {
|
|
55
|
+
/** Called whenever a fresh session was established, so it can be cached. */
|
|
56
|
+
readonly onSession?: (session: ZepSession) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class ZepClient {
|
|
60
|
+
private readonly account: ZepAccount;
|
|
61
|
+
private readonly root: string;
|
|
62
|
+
private readonly options: ZepClientOptions;
|
|
63
|
+
|
|
64
|
+
private cookies = new Map<string, string>();
|
|
65
|
+
private clientsessid: string | null = null;
|
|
66
|
+
private requesttoken: string | null = null;
|
|
67
|
+
private lastLogin: LoginResult | null = null;
|
|
68
|
+
|
|
69
|
+
constructor(account: ZepAccount, options: ZepClientOptions = {}) {
|
|
70
|
+
this.account = account;
|
|
71
|
+
this.root = `${account.baseUrl.replace(/\/+$/, "")}/${account.mandant.replace(/^\/+|\/+$/g, "")}`;
|
|
72
|
+
this.options = options;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ------------------------------------------------------------------ session
|
|
76
|
+
|
|
77
|
+
/** Adopt a cached session (from disk) so no login is needed. */
|
|
78
|
+
applySession(session: ZepSession): void {
|
|
79
|
+
this.clientsessid = session.clientsessid;
|
|
80
|
+
this.requesttoken = session.requesttoken;
|
|
81
|
+
this.cookies = new Map(Object.entries(session.cookies));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
currentSession(): ZepSession {
|
|
85
|
+
if (!this.clientsessid) throw new ZepError("Not logged in.");
|
|
86
|
+
return {
|
|
87
|
+
clientsessid: this.clientsessid,
|
|
88
|
+
requesttoken: this.requesttoken,
|
|
89
|
+
cookies: Object.fromEntries(this.cookies),
|
|
90
|
+
savedAt: new Date().toISOString(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get sessionId(): string | null {
|
|
95
|
+
return this.clientsessid;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get token(): string | null {
|
|
99
|
+
return this.requesttoken;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get lastLoginResult(): LoginResult | null {
|
|
103
|
+
return this.lastLogin;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** True when we hold both halves of a session. */
|
|
107
|
+
get hasSession(): boolean {
|
|
108
|
+
return this.clientsessid !== null && this.cookies.size > 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Log in with the stored credentials.
|
|
113
|
+
*
|
|
114
|
+
* `login.php` is a plain form POST (`userid`, `password`, `login`) with no
|
|
115
|
+
* CSRF token. Success is a 302 whose Location carries the new
|
|
116
|
+
* `CLIENTSESSID`; failure is a 302 back to `login.php?...&msg=N&noch=M`
|
|
117
|
+
* where `noch` is the remaining attempt count before a lockout.
|
|
118
|
+
*/
|
|
119
|
+
async login(): Promise<LoginResult> {
|
|
120
|
+
this.cookies.clear();
|
|
121
|
+
this.clientsessid = null;
|
|
122
|
+
this.requesttoken = null;
|
|
123
|
+
|
|
124
|
+
// 1) Prime the PHPSESSID cookie.
|
|
125
|
+
await this.raw("/view/login.php", { method: "GET" });
|
|
126
|
+
|
|
127
|
+
// 2) Post the credentials.
|
|
128
|
+
const body = new URLSearchParams({
|
|
129
|
+
userid: this.account.userid,
|
|
130
|
+
password: this.account.password,
|
|
131
|
+
login: "Anmelden",
|
|
132
|
+
}).toString();
|
|
133
|
+
|
|
134
|
+
const res = await this.raw("/view/login.php", {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
137
|
+
body,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const location = res.headers.get("location") ?? "";
|
|
141
|
+
let html = await res.text();
|
|
142
|
+
|
|
143
|
+
// ZEP always answers the login POST with a 302: either into the app
|
|
144
|
+
// (success) or back to login.php with `action=login&msg=N&noch=M`
|
|
145
|
+
// (failure). Follow it once either way - the failure text only exists on
|
|
146
|
+
// the rendered page, not in the redirect URL.
|
|
147
|
+
if (res.status >= 300 && res.status < 400 && location) {
|
|
148
|
+
const followed = await this.raw(relativize(location), { method: "GET" });
|
|
149
|
+
html = await followed.text();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const isLoginFailure = /action=(login|logout)/i.test(location) || /Eingabefehler/i.test(html);
|
|
153
|
+
const sid = isLoginFailure
|
|
154
|
+
? null
|
|
155
|
+
: (extractClientSessionId(location) ?? extractClientSessionId(html));
|
|
156
|
+
|
|
157
|
+
if (sid) {
|
|
158
|
+
this.clientsessid = sid;
|
|
159
|
+
this.requesttoken = extractRequestToken(html);
|
|
160
|
+
|
|
161
|
+
// Always finish by loading the booking page: it both proves the login
|
|
162
|
+
// worked and hands us the token every ajax call needs.
|
|
163
|
+
try {
|
|
164
|
+
await this.fetchPage();
|
|
165
|
+
} catch {
|
|
166
|
+
this.clientsessid = null;
|
|
167
|
+
this.cookies.clear();
|
|
168
|
+
this.lastLogin = { ok: false, message: "Login appeared to succeed but the app did not load." };
|
|
169
|
+
return this.lastLogin;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.lastLogin = { ok: true, clientsessid: sid };
|
|
173
|
+
this.persistSession();
|
|
174
|
+
return this.lastLogin;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const attemptsLeft = /noch\s+(\d+)/.exec(html)?.[1];
|
|
178
|
+
const message =
|
|
179
|
+
/Benutzername oder Kennwort falsch/i.test(html)
|
|
180
|
+
? "Benutzername oder Kennwort falsch."
|
|
181
|
+
: /gesperrt|locked/i.test(html)
|
|
182
|
+
? "Account locked."
|
|
183
|
+
: `Login failed (HTTP ${res.status}${location ? `, ${location}` : ""}).`;
|
|
184
|
+
|
|
185
|
+
this.lastLogin = {
|
|
186
|
+
ok: false,
|
|
187
|
+
message,
|
|
188
|
+
attemptsLeft: attemptsLeft ? Number(attemptsLeft) : undefined,
|
|
189
|
+
};
|
|
190
|
+
return this.lastLogin;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Log in only when there is no usable session yet. */
|
|
194
|
+
async ensureLogin(): Promise<void> {
|
|
195
|
+
if (this.hasSession) return;
|
|
196
|
+
const result = await this.login();
|
|
197
|
+
if (!result.ok) {
|
|
198
|
+
throw new ZepError(
|
|
199
|
+
`${result.message ?? "Login failed"}${
|
|
200
|
+
result.attemptsLeft !== undefined ? ` (${result.attemptsLeft} attempts left)` : ""
|
|
201
|
+
}`,
|
|
202
|
+
result.attemptsLeft,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private persistSession(): void {
|
|
208
|
+
if (!this.clientsessid) return;
|
|
209
|
+
try {
|
|
210
|
+
this.options.onSession?.(this.currentSession());
|
|
211
|
+
} catch {
|
|
212
|
+
// caching is best effort
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ------------------------------------------------------------------ plumbing
|
|
217
|
+
|
|
218
|
+
private url(path: string, query: Record<string, string> = {}): string {
|
|
219
|
+
const base = /^https?:/i.test(path) ? path : `${this.root}${path}`;
|
|
220
|
+
const search = new URLSearchParams(query).toString();
|
|
221
|
+
return `${base}${search ? `${base.includes("?") ? "&" : "?"}${search}` : ""}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async raw(
|
|
225
|
+
path: string,
|
|
226
|
+
init: RequestInit & { query?: Record<string, string> } = {},
|
|
227
|
+
): Promise<Response> {
|
|
228
|
+
const { query, ...rest } = init;
|
|
229
|
+
const headers = new Headers(rest.headers ?? {});
|
|
230
|
+
headers.set("user-agent", USER_AGENT);
|
|
231
|
+
headers.set("accept-language", "de-DE,de;q=0.9,en;q=0.8");
|
|
232
|
+
if (rest.method && rest.method !== "GET" && !headers.has("x-requested-with")) {
|
|
233
|
+
headers.set("x-requested-with", "XMLHttpRequest");
|
|
234
|
+
}
|
|
235
|
+
if (this.cookies.size > 0) {
|
|
236
|
+
headers.set(
|
|
237
|
+
"cookie",
|
|
238
|
+
[...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join("; "),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const res = await fetch(this.url(path, query), { redirect: "manual", ...rest, headers });
|
|
243
|
+
this.absorbCookies(res);
|
|
244
|
+
return res;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private absorbCookies(res: Response): void {
|
|
248
|
+
const setCookies =
|
|
249
|
+
typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : [];
|
|
250
|
+
for (const rawCookie of setCookies) {
|
|
251
|
+
const [pair] = rawCookie.split(";");
|
|
252
|
+
if (!pair) continue;
|
|
253
|
+
const idx = pair.indexOf("=");
|
|
254
|
+
if (idx === -1) continue;
|
|
255
|
+
const name = pair.slice(0, idx).trim();
|
|
256
|
+
const value = pair.slice(idx + 1).trim();
|
|
257
|
+
if (name) this.cookies.set(name, value);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build the ajax.php URL exactly the way the web UI does:
|
|
263
|
+
* base + JS env vars + `json=true` + `ajax=1`.
|
|
264
|
+
*/
|
|
265
|
+
private ajaxUrl(
|
|
266
|
+
action: string,
|
|
267
|
+
mgrId: string,
|
|
268
|
+
extra: Record<string, string> = {},
|
|
269
|
+
): { path: string; query: Record<string, string> } {
|
|
270
|
+
if (!this.clientsessid) throw new ZepError("Not logged in.");
|
|
271
|
+
return {
|
|
272
|
+
path: "/view/ajax.php",
|
|
273
|
+
query: {
|
|
274
|
+
CLIENTSESSID: this.clientsessid,
|
|
275
|
+
pageContextId: "Projektzeiten",
|
|
276
|
+
mgr: "ProjektzeitMgr",
|
|
277
|
+
mgrId,
|
|
278
|
+
action,
|
|
279
|
+
...extra,
|
|
280
|
+
JS_ENV_VAR_isFast: "true",
|
|
281
|
+
JS_ENV_VAR_locale: "de",
|
|
282
|
+
json: "true",
|
|
283
|
+
ajax: "1",
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async ajax(
|
|
289
|
+
action: string,
|
|
290
|
+
mgrId: string,
|
|
291
|
+
options: { method?: "GET" | "POST"; extra?: Record<string, string>; form?: Record<string, string> } = {},
|
|
292
|
+
) {
|
|
293
|
+
await this.ensureLogin();
|
|
294
|
+
// A request without a token is answered with a session logout, so never
|
|
295
|
+
// send one: fetch the page first when we do not have a fresh token yet.
|
|
296
|
+
if (!this.requesttoken) await this.fetchPage();
|
|
297
|
+
|
|
298
|
+
const { path, query } = this.ajaxUrl(action, mgrId, options.extra);
|
|
299
|
+
const method = options.method ?? "GET";
|
|
300
|
+
|
|
301
|
+
const headers: Record<string, string> = {
|
|
302
|
+
"x-requested-with": "XMLHttpRequest",
|
|
303
|
+
...(this.requesttoken ? { "x-requesttoken": this.requesttoken } : {}),
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
let body: string | undefined;
|
|
307
|
+
if (method === "POST") {
|
|
308
|
+
headers["content-type"] = "application/x-www-form-urlencoded; charset=UTF-8";
|
|
309
|
+
const form = { ...(options.form ?? {}) };
|
|
310
|
+
if (this.requesttoken) form.requesttoken = this.requesttoken;
|
|
311
|
+
body = new URLSearchParams(form).toString();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const res = await this.raw(path, { method, headers, body, query });
|
|
315
|
+
const text = await res.text();
|
|
316
|
+
const sessionLoss = detectSessionLoss(text);
|
|
317
|
+
if (sessionLoss) throw new ZepError(`ZEP ${sessionLoss}`);
|
|
318
|
+
const envelope = parseAjaxEnvelope(text);
|
|
319
|
+
if (envelope.requesttoken) this.requesttoken = envelope.requesttoken;
|
|
320
|
+
return envelope;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------- page
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Load the Projektzeiten page and read the requesttoken plus the option
|
|
327
|
+
* lists from the booking form.
|
|
328
|
+
*/
|
|
329
|
+
async loadForm(): Promise<{ form: ZepFormData; token: string | null }> {
|
|
330
|
+
await this.ensureLogin();
|
|
331
|
+
const html = await this.getPageHtml();
|
|
332
|
+
return { form: parseFormData(html), token: this.requesttoken };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Load the Projektzeiten page and pick up its requesttoken. */
|
|
336
|
+
private async fetchPage(): Promise<string> {
|
|
337
|
+
if (!this.clientsessid) throw new ZepError("Not logged in.");
|
|
338
|
+
const res = await this.raw("/view/index.php", {
|
|
339
|
+
query: {
|
|
340
|
+
CLIENTSESSID: this.clientsessid,
|
|
341
|
+
menu: "ProjektzeitVerwaltungMgr",
|
|
342
|
+
action: "save",
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
const html = await res.text();
|
|
346
|
+
const sessionLoss = detectSessionLoss(html);
|
|
347
|
+
if (sessionLoss) throw new ZepError(`ZEP ${sessionLoss}`);
|
|
348
|
+
const token = extractRequestToken(html);
|
|
349
|
+
if (token) this.requesttoken = token;
|
|
350
|
+
return html;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Like `fetchPage`, but recovers once from a stale session by logging in
|
|
355
|
+
* again. Used by the read paths.
|
|
356
|
+
*/
|
|
357
|
+
private async getPageHtml(): Promise<string> {
|
|
358
|
+
await this.ensureLogin();
|
|
359
|
+
try {
|
|
360
|
+
return await this.fetchPage();
|
|
361
|
+
} catch {
|
|
362
|
+
this.clientsessid = null;
|
|
363
|
+
this.cookies.clear();
|
|
364
|
+
const result = await this.login();
|
|
365
|
+
if (!result.ok) {
|
|
366
|
+
throw new ZepError(result.message ?? "Login failed", result.attemptsLeft);
|
|
367
|
+
}
|
|
368
|
+
return this.fetchPage();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// -------------------------------------------------------------------- actions
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Verify the session cheaply by reading the current week.
|
|
376
|
+
* Returns false when ZEP no longer accepts the session.
|
|
377
|
+
*/
|
|
378
|
+
async probe(): Promise<boolean> {
|
|
379
|
+
try {
|
|
380
|
+
await this.week();
|
|
381
|
+
return true;
|
|
382
|
+
} catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Week overview for the ISO week containing `isoDate` (default: today). */
|
|
388
|
+
async week(isoDate?: string): Promise<ZepWeek> {
|
|
389
|
+
const kwDate = isoDate ?? mondayOfToday();
|
|
390
|
+
const envelope = await this.ajax("setKw", TABLE_MGR_ID, { extra: { kwDate } });
|
|
391
|
+
const tableHtml = pickWeekTableSnippet(envelope.snippets)?.data ?? "";
|
|
392
|
+
return parseWeek(tableHtml, kwDate);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Turn an ajax envelope into a success/failure verdict.
|
|
397
|
+
*
|
|
398
|
+
* Verified against the live system:
|
|
399
|
+
* accepted -> `error: null`, `javascript: "dispatchZepEvent(...)"`
|
|
400
|
+
* rejected -> `error: "error"`, `javascript: null`, and the reason inside
|
|
401
|
+
* a `schnipsel` message slot (e.g. plan hours exceeded).
|
|
402
|
+
*/
|
|
403
|
+
private verdict(envelope: Awaited<ReturnType<ZepClient["ajax"]>>, successMessage: string): ZepSaveResult {
|
|
404
|
+
const snippetAlert = extractSnippetAlert(envelope.snippets);
|
|
405
|
+
if (snippetAlert && snippetAlert.kind !== "success") {
|
|
406
|
+
return { ok: false, error: snippetAlert.message };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const jsAlert = extractAlertMessage(envelope.javascript);
|
|
410
|
+
if (jsAlert) return { ok: false, error: jsAlert };
|
|
411
|
+
|
|
412
|
+
if (envelope.error === "error") {
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
error:
|
|
416
|
+
snippetAlert?.message ??
|
|
417
|
+
"ZEP rejected the request without a readable message.",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { ok: true, message: snippetAlert?.message ?? successMessage };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create one Projektzeit booking.
|
|
426
|
+
*
|
|
427
|
+
* ZEP rejects a booking whose duration would exceed the Vorgang's planned
|
|
428
|
+
* hours; that arrives as a normal JSON alert, not an HTTP error.
|
|
429
|
+
*/
|
|
430
|
+
async book(request: ZepBookingRequest): Promise<ZepSaveResult> {
|
|
431
|
+
const duration = request.duration || durationBetween(request.from, request.to) || "";
|
|
432
|
+
if (!duration) {
|
|
433
|
+
throw new ZepError(`Invalid time range: ${request.from} - ${request.to}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const form: Record<string, string> = {
|
|
437
|
+
ueberschreiben: "0",
|
|
438
|
+
doppelbuchungTrotzdemFragenObUeberschreiben: "1",
|
|
439
|
+
ueberschreibenTrotzDoppelbuchung: "0",
|
|
440
|
+
preisfaktorantwort: "",
|
|
441
|
+
datum: request.date,
|
|
442
|
+
von: request.from,
|
|
443
|
+
bis: request.to,
|
|
444
|
+
dauer: duration,
|
|
445
|
+
projektId: request.projektId,
|
|
446
|
+
vorgangId: request.vorgangId,
|
|
447
|
+
taetigkeit: request.taetigkeit,
|
|
448
|
+
ort: request.ort ?? "NULL",
|
|
449
|
+
letzterReiseOrt: "D-Office",
|
|
450
|
+
ortProjektrelevant: "1",
|
|
451
|
+
reise: "0",
|
|
452
|
+
startort: "Gottmadingen Im Brühl 1",
|
|
453
|
+
zielort: "Ismaningen Neoimpulse",
|
|
454
|
+
fahrzeug: "Firmenwagen",
|
|
455
|
+
km: "285",
|
|
456
|
+
mitfahrer: "1",
|
|
457
|
+
bemerkung: request.comment,
|
|
458
|
+
plusDays: "30",
|
|
459
|
+
plusMinutes: "0",
|
|
460
|
+
pastDays: "180",
|
|
461
|
+
pastDaysBezug: "0",
|
|
462
|
+
montagsErfassungFuerVorwoche: "1",
|
|
463
|
+
privat: "0",
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const envelope = await this.ajax("save", SAVE_MGR_ID, { method: "POST", form });
|
|
467
|
+
return this.verdict(envelope, "Buchung gespeichert.");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Delete a booking by its row id from `week()`. */
|
|
471
|
+
async remove(objectId: string, kwDate?: string): Promise<ZepSaveResult> {
|
|
472
|
+
const envelope = await this.ajax("delete", TABLE_MGR_ID, {
|
|
473
|
+
extra: {
|
|
474
|
+
userId: this.account.userid,
|
|
475
|
+
objectId,
|
|
476
|
+
...(kwDate ? { kwDate } : {}),
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
return this.verdict(envelope, "Buchung gelöscht.");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Ask ZEP to re-render the booking form for a different project so its
|
|
484
|
+
* Vorgang list becomes readable. This is the UI's own `formrefresh`
|
|
485
|
+
* mechanism (`formrefreshtrigger=projektId`), not a save.
|
|
486
|
+
*/
|
|
487
|
+
async refreshFormForProject(projektId: string): Promise<ZepFormData> {
|
|
488
|
+
const envelope = await this.ajax("save", SAVE_MGR_ID, {
|
|
489
|
+
method: "POST",
|
|
490
|
+
extra: { formrefresh: "true", formrefreshtrigger: "projektId" },
|
|
491
|
+
form: { projektId },
|
|
492
|
+
});
|
|
493
|
+
const html = envelope.snippets.map((s) => s.data).join("\n");
|
|
494
|
+
const form = parseFormData(html);
|
|
495
|
+
if (form.vorgaenge.length > 0 && form.projects.length === 0) {
|
|
496
|
+
// A partial re-render only carries the changed selects.
|
|
497
|
+
const full = await this.loadForm();
|
|
498
|
+
return {
|
|
499
|
+
...full.form,
|
|
500
|
+
vorgaenge: form.vorgaenge,
|
|
501
|
+
taetigkeiten: form.taetigkeiten.length ? form.taetigkeiten : full.form.taetigkeiten,
|
|
502
|
+
selected: {
|
|
503
|
+
...full.form.selected,
|
|
504
|
+
...form.selected,
|
|
505
|
+
projektId: form.selected.projektId ?? projektId,
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return form;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------- helpers
|
|
514
|
+
|
|
515
|
+
/** Turn an absolute redirect target into a path relative to the ZEP root. */
|
|
516
|
+
function relativize(location: string): string {
|
|
517
|
+
if (!/^https?:/i.test(location)) return location.startsWith("/") ? location : `/${location}`;
|
|
518
|
+
try {
|
|
519
|
+
const parsed = new URL(location);
|
|
520
|
+
const marker = `/${parsed.pathname.split("/").filter(Boolean)[0] ?? ""}`;
|
|
521
|
+
const withoutMandant = parsed.pathname.startsWith(marker)
|
|
522
|
+
? parsed.pathname.slice(marker.length)
|
|
523
|
+
: parsed.pathname;
|
|
524
|
+
return `${withoutMandant}${parsed.search}`;
|
|
525
|
+
} catch {
|
|
526
|
+
return location;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function mondayOfToday(): string {
|
|
531
|
+
const now = new Date();
|
|
532
|
+
const day = now.getUTCDay();
|
|
533
|
+
const diff = day === 0 ? -6 : 1 - day;
|
|
534
|
+
now.setUTCDate(now.getUTCDate() + diff);
|
|
535
|
+
return now.toISOString().slice(0, 10);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function parseFormData(html: string): ZepFormData {
|
|
539
|
+
const projects = parseSelect(html, "projektId");
|
|
540
|
+
const vorgaenge = parseSelect(html, "vorgangId");
|
|
541
|
+
const taetigkeiten = parseSelect(html, "taetigkeit");
|
|
542
|
+
const orte = parseSelect(html, "ort");
|
|
543
|
+
return {
|
|
544
|
+
projects,
|
|
545
|
+
vorgaenge,
|
|
546
|
+
taetigkeiten,
|
|
547
|
+
orte,
|
|
548
|
+
selected: {
|
|
549
|
+
projektId: selectedValue(projects),
|
|
550
|
+
vorgangId: selectedValue(vorgaenge),
|
|
551
|
+
taetigkeit: selectedValue(taetigkeiten),
|
|
552
|
+
ort: selectedValue(orte),
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Resolve a project/Vorgang/Tätigkeit reference that the user gave either as a
|
|
559
|
+
* numeric id or as (part of) a label. Returns the matching option, preferring
|
|
560
|
+
* an exact id match.
|
|
561
|
+
*/
|
|
562
|
+
export function resolveOption(
|
|
563
|
+
options: ReadonlyArray<{ id: string; label: string }>,
|
|
564
|
+
reference: string,
|
|
565
|
+
what: string,
|
|
566
|
+
): { id: string; label: string } {
|
|
567
|
+
const needle = reference.trim();
|
|
568
|
+
if (!needle) throw new ZepError(`No ${what} given.`);
|
|
569
|
+
|
|
570
|
+
const byId = options.find((o) => o.id === needle);
|
|
571
|
+
if (byId) return byId;
|
|
572
|
+
|
|
573
|
+
const lower = needle.toLowerCase();
|
|
574
|
+
const exact = options.find((o) => o.label.toLowerCase() === lower);
|
|
575
|
+
if (exact) return exact;
|
|
576
|
+
|
|
577
|
+
const partial = options.filter((o) => o.label.toLowerCase().includes(lower));
|
|
578
|
+
if (partial.length === 1) return partial[0]!;
|
|
579
|
+
if (partial.length === 0) {
|
|
580
|
+
throw new ZepError(
|
|
581
|
+
`No ${what} matches "${needle}". Available: ${options.map((o) => `${o.id}=${o.label}`).join(" | ") || "(none)"}`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
throw new ZepError(
|
|
585
|
+
`"${needle}" is ambiguous for ${what}: ${partial.map((o) => `${o.id}=${o.label}`).join(" | ")}`,
|
|
586
|
+
);
|
|
587
|
+
}
|