1312
db/migrations/0000_brief_dark_beast.sql
Normal file
1312
db/migrations/0000_brief_dark_beast.sql
Normal file
File diff suppressed because it is too large
Load Diff
32
db/migrations/0001_medical_big_bertha.sql
Normal file
32
db/migrations/0001_medical_big_bertha.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
CREATE TABLE "time_events" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" bigint NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"actor_type" text NOT NULL,
|
||||||
|
"actor_user_id" uuid,
|
||||||
|
"event_time" timestamp with time zone NOT NULL,
|
||||||
|
"event_type" text NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"invalidates_event_id" uuid,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "time_events_actor_user_check" CHECK (
|
||||||
|
(actor_type = 'system' AND actor_user_id IS NULL)
|
||||||
|
OR
|
||||||
|
(actor_type = 'user' AND actor_user_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "bankstatements" DROP CONSTRAINT "bankstatements_incomingInvoice_incominginvoices_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "bankstatements" DROP CONSTRAINT "bankstatements_createdDocument_createddocuments_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "time_events" ADD CONSTRAINT "time_events_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "time_events" ADD CONSTRAINT "time_events_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "time_events" ADD CONSTRAINT "time_events_actor_user_id_auth_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "time_events" ADD CONSTRAINT "time_events_invalidates_event_id_time_events_id_fk" FOREIGN KEY ("invalidates_event_id") REFERENCES "public"."time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_time_events_tenant_user_time" ON "time_events" USING btree ("tenant_id","user_id","event_time");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_time_events_created_at" ON "time_events" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_time_events_invalidates" ON "time_events" USING btree ("invalidates_event_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "bankstatements" DROP COLUMN "incomingInvoice";--> statement-breakpoint
|
||||||
|
ALTER TABLE "bankstatements" DROP COLUMN "createdDocument";
|
||||||
13
db/migrations/0002_silent_christian_walker.sql
Normal file
13
db/migrations/0002_silent_christian_walker.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE "time_events" RENAME TO "staff_time_events";--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" DROP CONSTRAINT "time_events_tenant_id_tenants_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" DROP CONSTRAINT "time_events_user_id_auth_users_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" DROP CONSTRAINT "time_events_actor_user_id_auth_users_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" DROP CONSTRAINT "time_events_invalidates_event_id_time_events_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_actor_user_id_auth_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_invalidates_event_id_staff_time_events_id_fk" FOREIGN KEY ("invalidates_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;
|
||||||
9788
db/migrations/meta/0000_snapshot.json
Normal file
9788
db/migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9947
db/migrations/meta/0001_snapshot.json
Normal file
9947
db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
41
db/migrations/meta/_journal.json
Normal file
41
db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1764947303113,
|
||||||
|
"tag": "0000_brief_dark_beast",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765641431341,
|
||||||
|
"tag": "0001_medical_big_bertha",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765642446738,
|
||||||
|
"tag": "0002_silent_christian_walker",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765716484200,
|
||||||
|
"tag": "0003_woozy_adam_destine",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765716877146,
|
||||||
|
"tag": "0004_stormy_onslaught",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -68,3 +68,4 @@ export * from "./units"
|
|||||||
export * from "./user_credentials"
|
export * from "./user_credentials"
|
||||||
export * from "./vehicles"
|
export * from "./vehicles"
|
||||||
export * from "./vendors"
|
export * from "./vendors"
|
||||||
|
export * from "./staff_time_events"
|
||||||
85
db/schema/staff_time_events.ts
Normal file
85
db/schema/staff_time_events.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
bigint,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
check,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {tenants} from "./tenants";
|
||||||
|
import {authUsers} from "./auth_users";
|
||||||
|
|
||||||
|
export const stafftimeevents = pgTable(
|
||||||
|
"staff_time_events",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
tenant_id: bigint("tenant_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id),
|
||||||
|
|
||||||
|
user_id: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authUsers.id),
|
||||||
|
|
||||||
|
// Akteur
|
||||||
|
actortype: text("actor_type").notNull(), // 'user' | 'system'
|
||||||
|
actoruser_id: uuid("actor_user_id").references(() => authUsers.id),
|
||||||
|
|
||||||
|
// Zeit
|
||||||
|
eventtime: timestamp("event_time", {
|
||||||
|
withTimezone: true,
|
||||||
|
}).notNull(),
|
||||||
|
|
||||||
|
// Fachliche Bedeutung
|
||||||
|
eventtype: text("event_type").notNull(),
|
||||||
|
|
||||||
|
// Quelle
|
||||||
|
source: text("source").notNull(), // web | mobile | terminal | system
|
||||||
|
|
||||||
|
// Entkräftung
|
||||||
|
invalidates_event_id: uuid("invalidates_event_id")
|
||||||
|
.references(() => stafftimeevents.id),
|
||||||
|
|
||||||
|
//Beziehung Approval etc
|
||||||
|
related_event_id: uuid("related_event_id")
|
||||||
|
.references(() => stafftimeevents.id),
|
||||||
|
|
||||||
|
// Zusatzdaten
|
||||||
|
metadata: jsonb("metadata"),
|
||||||
|
|
||||||
|
// Technisch
|
||||||
|
created_at: timestamp("created_at", {
|
||||||
|
withTimezone: true,
|
||||||
|
})
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// Indizes
|
||||||
|
tenantUserTimeIdx: index("idx_time_events_tenant_user_time").on(
|
||||||
|
table.tenant_id,
|
||||||
|
table.user_id,
|
||||||
|
table.eventtime
|
||||||
|
),
|
||||||
|
|
||||||
|
createdAtIdx: index("idx_time_events_created_at").on(table.created_at),
|
||||||
|
|
||||||
|
invalidatesIdx: index("idx_time_events_invalidates").on(
|
||||||
|
table.invalidates_event_id
|
||||||
|
),
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
actorUserCheck: check(
|
||||||
|
"time_events_actor_user_check",
|
||||||
|
sql`
|
||||||
|
(actor_type = 'system' AND actor_user_id IS NULL)
|
||||||
|
OR
|
||||||
|
(actor_type = 'user' AND actor_user_id IS NOT NULL)
|
||||||
|
`
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
14
src/index.ts
14
src/index.ts
@@ -35,6 +35,13 @@ import resourceRoutes from "./routes/resources/main";
|
|||||||
//M2M
|
//M2M
|
||||||
import authM2m from "./plugins/auth.m2m";
|
import authM2m from "./plugins/auth.m2m";
|
||||||
import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
|
import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
|
||||||
|
import deviceRoutes from "./routes/internal/devices";
|
||||||
|
import tenantRoutesInternal from "./routes/internal/tenant";
|
||||||
|
import staffTimeRoutesInternal from "./routes/internal/time";
|
||||||
|
|
||||||
|
//Devices
|
||||||
|
import devicesRFIDRoutes from "./routes/devices/rfid";
|
||||||
|
|
||||||
|
|
||||||
import {sendMail} from "./utils/mailer";
|
import {sendMail} from "./utils/mailer";
|
||||||
import {loadSecrets, secrets} from "./utils/secrets";
|
import {loadSecrets, secrets} from "./utils/secrets";
|
||||||
@@ -91,8 +98,15 @@ async function main() {
|
|||||||
await app.register(async (m2mApp) => {
|
await app.register(async (m2mApp) => {
|
||||||
await m2mApp.register(authM2m)
|
await m2mApp.register(authM2m)
|
||||||
await m2mApp.register(helpdeskInboundEmailRoutes)
|
await m2mApp.register(helpdeskInboundEmailRoutes)
|
||||||
|
await m2mApp.register(deviceRoutes)
|
||||||
|
await m2mApp.register(tenantRoutesInternal)
|
||||||
|
await m2mApp.register(staffTimeRoutesInternal)
|
||||||
},{prefix: "/internal"})
|
},{prefix: "/internal"})
|
||||||
|
|
||||||
|
await app.register(async (devicesApp) => {
|
||||||
|
await devicesApp.register(devicesRFIDRoutes)
|
||||||
|
},{prefix: "/devices"})
|
||||||
|
|
||||||
|
|
||||||
//Geschützte Routes
|
//Geschützte Routes
|
||||||
|
|
||||||
|
|||||||
229
src/modules/time/buildtimeevaluation.service.ts
Normal file
229
src/modules/time/buildtimeevaluation.service.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
// src/services/buildTimeEvaluationFromSpans.ts
|
||||||
|
|
||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { and, eq, gte, lte, inArray } from "drizzle-orm";
|
||||||
|
import { authProfiles, holidays } from "../../../db/schema";
|
||||||
|
import { DerivedSpan } from "./derivetimespans.service"; // Importiert den angereicherten Span-Typ
|
||||||
|
|
||||||
|
// Definiert das erwartete Rückgabeformat
|
||||||
|
export type TimeEvaluationResult = {
|
||||||
|
user_id: string;
|
||||||
|
tenant_id: number;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
|
||||||
|
// Sollzeit
|
||||||
|
timeSpanWorkingMinutes: number;
|
||||||
|
|
||||||
|
// Arbeitszeit Salden
|
||||||
|
sumWorkingMinutesSubmitted: number;
|
||||||
|
sumWorkingMinutesApproved: number;
|
||||||
|
|
||||||
|
// Abwesenheiten (minuten und Tage)
|
||||||
|
sumWorkingMinutesRecreationDays: number;
|
||||||
|
sumRecreationDays: number;
|
||||||
|
sumWorkingMinutesVacationDays: number;
|
||||||
|
sumVacationDays: number;
|
||||||
|
sumWorkingMinutesSickDays: number;
|
||||||
|
sumSickDays: number;
|
||||||
|
|
||||||
|
// Endsalden
|
||||||
|
saldoApproved: number; // Saldo basierend auf genehmigter Zeit
|
||||||
|
saldoSubmitted: number; // Saldo basierend auf eingereichter/genehmigter Zeit
|
||||||
|
|
||||||
|
spans: DerivedSpan[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hilfsfunktion zur Berechnung der Minuten (nur für geschlossene Spannen)
|
||||||
|
const calcMinutes = (start: Date, end: Date | null): number => {
|
||||||
|
if (!end) return 0;
|
||||||
|
return (end.getTime() - start.getTime()) / 60000;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export async function buildTimeEvaluationFromSpans(
|
||||||
|
server: FastifyInstance,
|
||||||
|
user_id: string,
|
||||||
|
tenant_id: number,
|
||||||
|
startDateInput: string,
|
||||||
|
endDateInput: string,
|
||||||
|
// Der wichtigste Unterschied: Wir nehmen die angereicherten Spannen als Input
|
||||||
|
spans: DerivedSpan[]
|
||||||
|
): Promise<TimeEvaluationResult> {
|
||||||
|
|
||||||
|
const startDate = server.dayjs(startDateInput);
|
||||||
|
const endDate = server.dayjs(endDateInput);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 1️⃣ Profil und Feiertage laden (WIE IM ALTEN SERVICE)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
const profileRows = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(authProfiles.user_id, user_id),
|
||||||
|
eq(authProfiles.tenant_id, tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const profile = profileRows[0];
|
||||||
|
if (!profile) throw new Error("Profil konnte nicht geladen werden.");
|
||||||
|
|
||||||
|
const holidaysRows = await server.db
|
||||||
|
.select({
|
||||||
|
date: holidays.date,
|
||||||
|
})
|
||||||
|
.from(holidays)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(holidays.state_code, [profile.state_code, "DE"]),
|
||||||
|
gte(holidays.date, startDate.format("YYYY-MM-DD")),
|
||||||
|
lte(holidays.date, endDate.add(1, "day").format("YYYY-MM-DD"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 2️⃣ Sollzeit berechnen (WIE IM ALTEN SERVICE)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
let timeSpanWorkingMinutes = 0;
|
||||||
|
const totalDays = endDate.add(1, "day").diff(startDate, "days");
|
||||||
|
|
||||||
|
for (let i = 0; i < totalDays; i++) {
|
||||||
|
const date = startDate.add(i, "days");
|
||||||
|
const weekday = date.day();
|
||||||
|
timeSpanWorkingMinutes +=
|
||||||
|
(profile.weekly_regular_working_hours?.[weekday] || 0) * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 3️⃣ Arbeits- und Abwesenheitszeiten berechnen (NEUE LOGIK)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
let sumWorkingMinutesSubmitted = 0;
|
||||||
|
let sumWorkingMinutesApproved = 0;
|
||||||
|
|
||||||
|
let sumWorkingMinutesVacationDays = 0;
|
||||||
|
let sumVacationDays = 0;
|
||||||
|
let sumWorkingMinutesSickDays = 0;
|
||||||
|
let sumSickDays = 0;
|
||||||
|
|
||||||
|
// Akkumulieren der Zeiten basierend auf dem abgeleiteten Typ und Status
|
||||||
|
for (const span of spans) {
|
||||||
|
|
||||||
|
// **A. Arbeitszeiten (WORK)**
|
||||||
|
if (span.type === "work") {
|
||||||
|
const minutes = calcMinutes(span.startedAt, span.endedAt);
|
||||||
|
|
||||||
|
// Zähle zur eingereichten Summe, wenn der Status submitted oder approved ist
|
||||||
|
if (span.status === "submitted" || span.status === "approved") {
|
||||||
|
sumWorkingMinutesSubmitted += minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zähle zur genehmigten Summe, wenn der Status approved ist
|
||||||
|
if (span.status === "approved") {
|
||||||
|
sumWorkingMinutesApproved += minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **B. Abwesenheiten (VACATION, SICK)**
|
||||||
|
// Wir verwenden die Logik aus dem alten Service: Berechnung der Sollzeit
|
||||||
|
// basierend auf den Tagen der Span (Voraussetzung: Spannen sind Volltages-Spannen)
|
||||||
|
if (span.type === "vacation" || span.type === "sick") {
|
||||||
|
|
||||||
|
// Behandle nur genehmigte Abwesenheiten für die Saldenberechnung
|
||||||
|
if (span.status !== "approved") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDay = server.dayjs(span.startedAt).startOf('day');
|
||||||
|
// Wenn endedAt null ist (offene Span), nehmen wir das Ende des Zeitraums
|
||||||
|
const endDay = span.endedAt ? server.dayjs(span.endedAt).startOf('day') : endDate.startOf('day');
|
||||||
|
|
||||||
|
// Berechnung der Tage der Span
|
||||||
|
const days = endDay.diff(startDay, "day") + 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < days; i++) {
|
||||||
|
const day = startDay.add(i, "day");
|
||||||
|
const weekday = day.day();
|
||||||
|
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
|
||||||
|
|
||||||
|
if (span.type === "vacation") {
|
||||||
|
sumWorkingMinutesVacationDays += hours * 60;
|
||||||
|
} else if (span.type === "sick") {
|
||||||
|
sumWorkingMinutesSickDays += hours * 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (span.type === "vacation") {
|
||||||
|
sumVacationDays += days;
|
||||||
|
} else if (span.type === "sick") {
|
||||||
|
sumSickDays += days;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAUSE Spannen werden ignoriert, da sie in der faktischen Ableitung bereits von WORK abgezogen wurden.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 4️⃣ Feiertagsausgleich (WIE IM ALTEN SERVICE)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
let sumWorkingMinutesRecreationDays = 0;
|
||||||
|
let sumRecreationDays = 0;
|
||||||
|
|
||||||
|
if (profile.recreation_days_compensation && holidaysRows?.length) {
|
||||||
|
holidaysRows.forEach(({ date }) => {
|
||||||
|
const weekday = server.dayjs(date).day();
|
||||||
|
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
|
||||||
|
sumWorkingMinutesRecreationDays += hours * 60;
|
||||||
|
sumRecreationDays++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 5️⃣ Salden berechnen (NEUE LOGIK)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
const totalCompensatedMinutes =
|
||||||
|
sumWorkingMinutesRecreationDays +
|
||||||
|
sumWorkingMinutesVacationDays +
|
||||||
|
sumWorkingMinutesSickDays;
|
||||||
|
|
||||||
|
// Saldo basierend auf GENEHMIGTER Arbeitszeit
|
||||||
|
const totalApprovedMinutes = sumWorkingMinutesApproved + totalCompensatedMinutes;
|
||||||
|
const saldoApproved = totalApprovedMinutes - timeSpanWorkingMinutes;
|
||||||
|
|
||||||
|
// Saldo basierend auf EINGEREICHTER und GENEHMIGTER Arbeitszeit
|
||||||
|
const totalSubmittedMinutes = sumWorkingMinutesSubmitted + totalCompensatedMinutes;
|
||||||
|
const saldoSubmitted = totalSubmittedMinutes - timeSpanWorkingMinutes;
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 6️⃣ Rückgabe
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
return {
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
from: startDate.format("YYYY-MM-DD"),
|
||||||
|
to: endDate.format("YYYY-MM-DD"),
|
||||||
|
timeSpanWorkingMinutes,
|
||||||
|
|
||||||
|
sumWorkingMinutesSubmitted,
|
||||||
|
sumWorkingMinutesApproved,
|
||||||
|
|
||||||
|
sumWorkingMinutesRecreationDays,
|
||||||
|
sumRecreationDays,
|
||||||
|
sumWorkingMinutesVacationDays,
|
||||||
|
sumVacationDays,
|
||||||
|
sumWorkingMinutesSickDays,
|
||||||
|
sumSickDays,
|
||||||
|
|
||||||
|
saldoApproved,
|
||||||
|
saldoSubmitted,
|
||||||
|
spans,
|
||||||
|
};
|
||||||
|
}
|
||||||
165
src/modules/time/derivetimespans.service.ts
Normal file
165
src/modules/time/derivetimespans.service.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
type State = "IDLE" | "WORKING" | "PAUSED" | "ABSENT";
|
||||||
|
|
||||||
|
export type SpanStatus = "factual" | "submitted" | "approved" | "rejected";
|
||||||
|
|
||||||
|
export type DerivedSpan = {
|
||||||
|
type: "work" | "pause" | "vacation" | "sick" | "overtime_compensation";
|
||||||
|
startedAt: Date;
|
||||||
|
endedAt: Date | null;
|
||||||
|
sourceEventIds: string[];
|
||||||
|
status: SpanStatus;
|
||||||
|
statusActorId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimeEvent = {
|
||||||
|
id: string;
|
||||||
|
eventtype: string;
|
||||||
|
eventtime: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
|
||||||
|
const FACTUAL_EVENT_TYPES = new Set([
|
||||||
|
"work_start",
|
||||||
|
"pause_start",
|
||||||
|
"pause_end",
|
||||||
|
"work_end",
|
||||||
|
"auto_stop", // Wird als work_end behandelt
|
||||||
|
"vacation_start",
|
||||||
|
"vacation_end",
|
||||||
|
"sick_start",
|
||||||
|
"sick_end",
|
||||||
|
"overtime_compensation_start",
|
||||||
|
"overtime_compensation_end",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||||
|
|
||||||
|
// 1. FILTERN: Nur faktische Events verarbeiten
|
||||||
|
const events = allValidEvents.filter(event =>
|
||||||
|
FACTUAL_EVENT_TYPES.has(event.eventtype)
|
||||||
|
);
|
||||||
|
|
||||||
|
const spans: DerivedSpan[] = [];
|
||||||
|
|
||||||
|
let state: State = "IDLE";
|
||||||
|
let currentStart: Date | null = null;
|
||||||
|
let currentType: DerivedSpan["type"] | null = null;
|
||||||
|
let sourceEventIds: string[] = [];
|
||||||
|
|
||||||
|
const closeSpan = (end: Date) => {
|
||||||
|
if (!currentStart || !currentType) return;
|
||||||
|
|
||||||
|
spans.push({
|
||||||
|
type: currentType,
|
||||||
|
startedAt: currentStart,
|
||||||
|
endedAt: end,
|
||||||
|
sourceEventIds: [...sourceEventIds],
|
||||||
|
// Standardstatus ist "factual", wird später angereichert
|
||||||
|
status: "factual"
|
||||||
|
});
|
||||||
|
|
||||||
|
currentStart = null;
|
||||||
|
currentType = null;
|
||||||
|
sourceEventIds = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeOpenSpanAsRunning = () => {
|
||||||
|
if (!currentStart || !currentType) return;
|
||||||
|
|
||||||
|
spans.push({
|
||||||
|
type: currentType,
|
||||||
|
startedAt: currentStart,
|
||||||
|
endedAt: null,
|
||||||
|
sourceEventIds: [...sourceEventIds],
|
||||||
|
// Standardstatus ist "factual", wird später angereichert
|
||||||
|
status: "factual"
|
||||||
|
});
|
||||||
|
|
||||||
|
currentStart = null;
|
||||||
|
currentType = null;
|
||||||
|
sourceEventIds = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
sourceEventIds.push(event.id);
|
||||||
|
|
||||||
|
switch (event.eventtype) {
|
||||||
|
/* =========================
|
||||||
|
ARBEITSZEIT
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
case "work_start":
|
||||||
|
if (state === "WORKING" || state === "PAUSED" || state === "ABSENT") {
|
||||||
|
// Schließt die vorherige Spanne (falls z.B. work_start nach sick_start kommt)
|
||||||
|
closeSpan(event.eventtime);
|
||||||
|
}
|
||||||
|
state = "WORKING";
|
||||||
|
currentStart = event.eventtime;
|
||||||
|
currentType = "work";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pause_start":
|
||||||
|
if (state === "WORKING") {
|
||||||
|
closeSpan(event.eventtime);
|
||||||
|
state = "PAUSED";
|
||||||
|
currentStart = event.eventtime;
|
||||||
|
currentType = "pause";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pause_end":
|
||||||
|
if (state === "PAUSED") {
|
||||||
|
closeSpan(event.eventtime);
|
||||||
|
state = "WORKING";
|
||||||
|
currentStart = event.eventtime;
|
||||||
|
currentType = "work";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "work_end":
|
||||||
|
case "auto_stop":
|
||||||
|
if (state === "WORKING" || state === "PAUSED") {
|
||||||
|
closeSpan(event.eventtime);
|
||||||
|
}
|
||||||
|
state = "IDLE";
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
ABWESENHEITEN
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
case "vacation_start":
|
||||||
|
case "sick_start":
|
||||||
|
case "overtime_compensation_start":
|
||||||
|
// Mappt den Event-Typ direkt auf den Span-Typ
|
||||||
|
const newType = event.eventtype.split('_')[0] as DerivedSpan["type"];
|
||||||
|
|
||||||
|
if (state !== "IDLE") {
|
||||||
|
closeSpan(event.eventtime);
|
||||||
|
}
|
||||||
|
state = "ABSENT";
|
||||||
|
currentStart = event.eventtime;
|
||||||
|
currentType = newType;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "vacation_end":
|
||||||
|
case "sick_end":
|
||||||
|
case "overtime_compensation_end":
|
||||||
|
// Extrahiert den Typ der zu beendenden Spanne
|
||||||
|
const endedType = event.eventtype.split('_')[0] as DerivedSpan["type"];
|
||||||
|
|
||||||
|
if (state === "ABSENT" && currentType === endedType) {
|
||||||
|
closeSpan(event.eventtime);
|
||||||
|
}
|
||||||
|
state = "IDLE";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔴 WICHTIG: Offene Spannen als laufend zurückgeben
|
||||||
|
if (state !== "IDLE") {
|
||||||
|
closeOpenSpanAsRunning();
|
||||||
|
}
|
||||||
|
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
91
src/modules/time/enrichtimespanswithstatus.service.ts
Normal file
91
src/modules/time/enrichtimespanswithstatus.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// src/services/enrichSpansWithStatus.ts (Korrigierte Version)
|
||||||
|
|
||||||
|
import { DerivedSpan, SpanStatus } from "./derivetimespans.service";
|
||||||
|
import { TimeEvent } from "./loadvalidevents.service"; // Jetzt mit related_event_id und actoruser_id
|
||||||
|
|
||||||
|
// ... (Rest der Imports)
|
||||||
|
|
||||||
|
export function enrichSpansWithStatus(
|
||||||
|
factualSpans: DerivedSpan[],
|
||||||
|
allValidEvents: TimeEvent[]
|
||||||
|
): DerivedSpan[] {
|
||||||
|
|
||||||
|
// 1. Map der administrativen Aktionen erstellen
|
||||||
|
const eventStatusMap = new Map<string, { status: SpanStatus, actorId: string }>();
|
||||||
|
|
||||||
|
const administrativeEvents = allValidEvents.filter(e =>
|
||||||
|
e.eventtype === 'submitted' || e.eventtype === 'approved' || e.eventtype === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
// allValidEvents ist nach Zeit sortiert
|
||||||
|
for (const event of administrativeEvents) {
|
||||||
|
|
||||||
|
// **Verwendung des expliziten Feldes**
|
||||||
|
const relatedId = event.related_event_id;
|
||||||
|
const actorId = event.actoruser_id;
|
||||||
|
|
||||||
|
if (relatedId) { // Nur fortfahren, wenn ein Bezugs-Event existiert
|
||||||
|
|
||||||
|
let status: SpanStatus = "factual";
|
||||||
|
// Wir überschreiben den Status des relatedId basierend auf der Event-Historie
|
||||||
|
if (event.eventtype === 'submitted') status = 'submitted';
|
||||||
|
else if (event.eventtype === 'approved') status = 'approved';
|
||||||
|
else if (event.eventtype === 'rejected') status = 'rejected';
|
||||||
|
|
||||||
|
eventStatusMap.set(relatedId, { status, actorId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Status der Spannen bestimmen und anreichern
|
||||||
|
return factualSpans.map(span => {
|
||||||
|
|
||||||
|
let approvedCount = 0;
|
||||||
|
let rejectedCount = 0;
|
||||||
|
let submittedCount = 0;
|
||||||
|
let isFactualCount = 0;
|
||||||
|
|
||||||
|
for (const sourceId of span.sourceEventIds) {
|
||||||
|
const statusInfo = eventStatusMap.get(sourceId);
|
||||||
|
|
||||||
|
if (statusInfo) {
|
||||||
|
// Ein faktisches Event kann durch mehrere administrative Events betroffen sein
|
||||||
|
// Wir speichern im Map nur den letzten Status (z.B. approved überschreibt submitted)
|
||||||
|
if (statusInfo.status === 'approved') approvedCount++;
|
||||||
|
else if (statusInfo.status === 'rejected') rejectedCount++;
|
||||||
|
else if (statusInfo.status === 'submitted') submittedCount++;
|
||||||
|
} else {
|
||||||
|
// Wenn kein administratives Event existiert
|
||||||
|
isFactualCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regel zur Bestimmung des Span-Status:
|
||||||
|
const totalSourceEvents = span.sourceEventIds.length;
|
||||||
|
let finalStatus: SpanStatus = "factual";
|
||||||
|
|
||||||
|
if (totalSourceEvents > 0) {
|
||||||
|
|
||||||
|
// Priorität 1: Rejection
|
||||||
|
if (rejectedCount > 0) {
|
||||||
|
finalStatus = "rejected";
|
||||||
|
}
|
||||||
|
// Priorität 2: Full Approval
|
||||||
|
else if (approvedCount === totalSourceEvents) {
|
||||||
|
finalStatus = "approved";
|
||||||
|
}
|
||||||
|
// Priorität 3: Submitted (wenn nicht fully approved oder rejected, aber mindestens eines submitted ist)
|
||||||
|
else if (submittedCount > 0 || approvedCount > 0) {
|
||||||
|
finalStatus = "submitted";
|
||||||
|
// Ein Span ist submitted, wenn es zumindest teilweise eingereicht (oder genehmigt) ist,
|
||||||
|
// aber nicht alle Events den finalen Status "approved" haben.
|
||||||
|
}
|
||||||
|
// Ansonsten bleibt es "factual" (wenn z.B. nur work_start aber nicht work_end eingereicht wurde, oder nichts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rückgabe der angereicherten Span
|
||||||
|
return {
|
||||||
|
...span,
|
||||||
|
status: finalStatus,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
105
src/modules/time/loadvalidevents.service.ts
Normal file
105
src/modules/time/loadvalidevents.service.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// src/services/loadValidEvents.ts
|
||||||
|
|
||||||
|
import { stafftimeevents } from "../../../db/schema";
|
||||||
|
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
|
||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
|
||||||
|
|
||||||
|
|
||||||
|
// Die Definition des TimeEvent Typs, der zurückgegeben wird (muss mit dem tatsächlichen Typ übereinstimmen)
|
||||||
|
export type TimeEvent = {
|
||||||
|
id: string;
|
||||||
|
eventtype: string;
|
||||||
|
eventtime: Date;
|
||||||
|
actoruser_id: string;
|
||||||
|
related_event_id: string | null;
|
||||||
|
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadValidEvents(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
userId: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<TimeEvent[]> {
|
||||||
|
// Definieren Sie einen Alias für die stafftimeevents Tabelle in der äußeren Abfrage
|
||||||
|
const baseEvents = stafftimeevents;
|
||||||
|
|
||||||
|
// Die Subquery, um alle IDs zu finden, die ungültig gemacht wurden
|
||||||
|
// Wir nennen die innere Tabelle 'invalidatingEvents'
|
||||||
|
const invalidatingEvents = server.db
|
||||||
|
.select({
|
||||||
|
invalidatedId: baseEvents.invalidates_event_id
|
||||||
|
})
|
||||||
|
.from(baseEvents)
|
||||||
|
.as('invalidating_events');
|
||||||
|
|
||||||
|
// Die Hauptabfrage
|
||||||
|
const result = await server.db
|
||||||
|
.select()
|
||||||
|
.from(baseEvents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
// 1. Tenant und User filtern
|
||||||
|
eq(baseEvents.tenant_id, tenantId),
|
||||||
|
eq(baseEvents.user_id, userId),
|
||||||
|
|
||||||
|
// 2. Zeitbereich filtern (Typensicher)
|
||||||
|
gte(baseEvents.eventtime, from),
|
||||||
|
lte(baseEvents.eventtime, to),
|
||||||
|
|
||||||
|
// 3. WICHTIG: Korrekturen ausschließen (NOT EXISTS)
|
||||||
|
// Schließe jedes Event aus, dessen ID in der Liste der invalidates_event_id erscheint.
|
||||||
|
sql`
|
||||||
|
not exists (
|
||||||
|
select 1
|
||||||
|
from ${stafftimeevents} i
|
||||||
|
where i.invalidates_event_id = ${baseEvents.id}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
|
||||||
|
baseEvents.eventtime,
|
||||||
|
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
|
||||||
|
baseEvents.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mapping auf den sauberen TimeEvent Typ
|
||||||
|
return result.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
eventtype: e.eventtype,
|
||||||
|
eventtime: e.eventtime,
|
||||||
|
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
|
||||||
|
// ...
|
||||||
|
})) as TimeEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRelatedAdminEvents(server, eventIds) {
|
||||||
|
if (eventIds.length === 0) return [];
|
||||||
|
|
||||||
|
// Lädt alle administrativen Events, die sich auf die faktischen Event-IDs beziehen
|
||||||
|
const adminEvents = await server.db
|
||||||
|
.select()
|
||||||
|
.from(stafftimeevents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(stafftimeevents.related_event_id, eventIds),
|
||||||
|
// Wir müssen hier die Entkräftung prüfen, um z.B. einen abgelehnten submitted-Event auszuschließen
|
||||||
|
sql`
|
||||||
|
not exists (
|
||||||
|
select 1
|
||||||
|
from ${stafftimeevents} i
|
||||||
|
where i.invalidates_event_id = ${stafftimeevents}.id
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
|
||||||
|
.orderBy(stafftimeevents.eventtime);
|
||||||
|
|
||||||
|
return adminEvents;
|
||||||
|
}
|
||||||
98
src/routes/devices/rfid.ts
Normal file
98
src/routes/devices/rfid.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import {and, desc, eq} from "drizzle-orm";
|
||||||
|
import {authProfiles, devices, stafftimeevents} from "../../../db/schema";
|
||||||
|
|
||||||
|
export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||||
|
server.post(
|
||||||
|
"/rfid/createevent/:terminal_id",
|
||||||
|
async (req, reply) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const {rfid_id} = req.body as {rfid_id: string};
|
||||||
|
const {terminal_id} = req.params as {terminal_id: string};
|
||||||
|
|
||||||
|
if(!rfid_id ||!terminal_id) {
|
||||||
|
console.log(`Missing Params`);
|
||||||
|
return reply.code(400).send(`Missing Params`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await server.db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
eq(devices.externalId, terminal_id)
|
||||||
|
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.then(rows => rows[0]);
|
||||||
|
|
||||||
|
if(!device) {
|
||||||
|
console.log(`Device ${terminal_id} not found`);
|
||||||
|
return reply.code(400).send(`Device ${terminal_id} not found`)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(authProfiles.tenant_id, device.tenant),
|
||||||
|
eq(authProfiles.token_id, rfid_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.then(rows => rows[0]);
|
||||||
|
|
||||||
|
if(!profile) {
|
||||||
|
console.log(`Profile for Token ${rfid_id} not found`);
|
||||||
|
return reply.code(400).send(`Profile for Token ${rfid_id} not found`)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEvent = await server.db
|
||||||
|
.select()
|
||||||
|
.from(stafftimeevents)
|
||||||
|
.where(
|
||||||
|
eq(stafftimeevents.user_id, profile.user_id)
|
||||||
|
)
|
||||||
|
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
|
||||||
|
.limit(1)
|
||||||
|
.then(rows => rows[0]);
|
||||||
|
|
||||||
|
console.log(lastEvent)
|
||||||
|
|
||||||
|
|
||||||
|
const dataToInsert = {
|
||||||
|
tenant_id: device.tenant,
|
||||||
|
user_id: profile.user_id,
|
||||||
|
actortype: "system",
|
||||||
|
eventtime: new Date(),
|
||||||
|
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
|
||||||
|
source: "WEB"
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(dataToInsert)
|
||||||
|
|
||||||
|
const [created] = await server.db
|
||||||
|
.insert(stafftimeevents)
|
||||||
|
//@ts-ignore
|
||||||
|
.values(dataToInsert)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return created
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
return reply.code(400).send({ error: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
console.log(req.body)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/routes/internal/devices.ts
Normal file
41
src/routes/internal/devices.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { devices } from "../../../db/schema";
|
||||||
|
|
||||||
|
export default async function deviceRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.get<{
|
||||||
|
Params: {
|
||||||
|
externalId: string;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
"/devices/by-external-id/:externalId",
|
||||||
|
async (request, reply) => {
|
||||||
|
const { externalId } = request.params;
|
||||||
|
|
||||||
|
const device = await fastify.db
|
||||||
|
.select({
|
||||||
|
id: devices.id,
|
||||||
|
name: devices.name,
|
||||||
|
type: devices.type,
|
||||||
|
tenant: devices.tenant,
|
||||||
|
externalId: devices.externalId,
|
||||||
|
created_at: devices.createdAt,
|
||||||
|
})
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
eq(devices.externalId, externalId)
|
||||||
|
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.then(rows => rows[0]);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
message: "Device not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(device);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/routes/internal/tenant.ts
Normal file
107
src/routes/internal/tenant.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
|
||||||
|
import {
|
||||||
|
authTenantUsers,
|
||||||
|
authUsers,
|
||||||
|
authProfiles,
|
||||||
|
tenants
|
||||||
|
} from "../../../db/schema"
|
||||||
|
|
||||||
|
import {and, eq, inArray} from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
|
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GET CURRENT TENANT
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/tenant/:id", async (req) => {
|
||||||
|
//@ts-ignore
|
||||||
|
const tenant = (await server.db.select().from(tenants).where(eq(tenants.id,req.params.id)).limit(1))[0]
|
||||||
|
|
||||||
|
return tenant
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// TENANT USERS (auth_users + auth_profiles)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/tenant/users", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const authUser = req.user
|
||||||
|
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const tenantId = authUser.tenant_id
|
||||||
|
|
||||||
|
// 1) auth_tenant_users → user_ids
|
||||||
|
const tenantUsers = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authTenantUsers)
|
||||||
|
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||||
|
|
||||||
|
const userIds = tenantUsers.map(u => u.user_id)
|
||||||
|
|
||||||
|
if (!userIds.length) {
|
||||||
|
return { tenant_id: tenantId, users: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) auth_users laden
|
||||||
|
const users = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authUsers)
|
||||||
|
.where(inArray(authUsers.id, userIds))
|
||||||
|
|
||||||
|
// 3) auth_profiles pro Tenant laden
|
||||||
|
const profiles = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
|
inArray(authProfiles.user_id, userIds)
|
||||||
|
))
|
||||||
|
|
||||||
|
const combined = users.map(u => {
|
||||||
|
const profile = profiles.find(p => p.user_id === u.id)
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
profile,
|
||||||
|
full_name: profile?.full_name ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { tenant_id: tenantId, users: combined }
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/tenant/users ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// TENANT PROFILES
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/tenant/:id/profiles", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const tenantId = req.params.id
|
||||||
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const data = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(eq(authProfiles.tenant_id, tenantId))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/tenant/profiles ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
122
src/routes/internal/time.ts
Normal file
122
src/routes/internal/time.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import {
|
||||||
|
stafftimeentries,
|
||||||
|
stafftimenetryconnects
|
||||||
|
} from "../../../db/schema"
|
||||||
|
import {
|
||||||
|
eq,
|
||||||
|
and,
|
||||||
|
gte,
|
||||||
|
lte,
|
||||||
|
desc
|
||||||
|
} from "drizzle-orm"
|
||||||
|
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||||
|
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||||
|
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||||
|
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||||
|
|
||||||
|
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
||||||
|
|
||||||
|
|
||||||
|
server.post("/staff/time/event", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const body = req.body as {user_id:string,tenant_id:number,eventtime:string,eventtype:string}
|
||||||
|
|
||||||
|
const normalizeDate = (val: any) => {
|
||||||
|
if (!val) return null
|
||||||
|
const d = new Date(val)
|
||||||
|
return isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToInsert = {
|
||||||
|
tenant_id: body.tenant_id,
|
||||||
|
user_id: body.user_id,
|
||||||
|
actortype: "user",
|
||||||
|
actoruser_id: body.user_id,
|
||||||
|
eventtime: normalizeDate(body.eventtime),
|
||||||
|
eventtype: body.eventtype,
|
||||||
|
source: "WEB"
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(dataToInsert)
|
||||||
|
|
||||||
|
const [created] = await server.db
|
||||||
|
.insert(stafftimeevents)
|
||||||
|
//@ts-ignore
|
||||||
|
.values(dataToInsert)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return created
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
return reply.code(400).send({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// GET /api/staff/time/spans
|
||||||
|
server.get("/staff/time/spans", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Query-Parameter: targetUserId ist optional
|
||||||
|
const { targetUserId, tenantId} = req.query as { targetUserId: string, tenantId:number };
|
||||||
|
|
||||||
|
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
|
||||||
|
const evaluatedUserId = targetUserId;
|
||||||
|
|
||||||
|
// 💡 "Unendlicher" Zeitraum, wie gewünscht
|
||||||
|
const startDate = new Date(0); // 1970
|
||||||
|
const endDate = new Date("2100-12-31");
|
||||||
|
|
||||||
|
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
|
||||||
|
const allEventsInTimeFrame = await loadValidEvents(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// SCHRITT 2: Filtere faktische Events
|
||||||
|
const FACTUAL_EVENT_TYPES = new Set([
|
||||||
|
"work_start", "work_end", "pause_start", "pause_end",
|
||||||
|
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||||
|
"overtime_compensation_start", "overtime_compensation_end",
|
||||||
|
"auto_stop"
|
||||||
|
]);
|
||||||
|
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||||
|
|
||||||
|
// SCHRITT 3: Hole administrative Events
|
||||||
|
const factualEventIds = factualEvents.map(e => e.id);
|
||||||
|
|
||||||
|
if (factualEventIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||||
|
|
||||||
|
// SCHRITT 4: Kombinieren und Sortieren
|
||||||
|
const combinedEvents = [
|
||||||
|
...factualEvents,
|
||||||
|
...relatedAdminEvents,
|
||||||
|
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||||
|
|
||||||
|
// SCHRITT 5: Spans ableiten
|
||||||
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
|
|
||||||
|
// SCHRITT 6: Spans anreichern
|
||||||
|
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||||
|
|
||||||
|
return enrichedSpans;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Laden der Spans:", error);
|
||||||
|
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -10,10 +10,334 @@ import {
|
|||||||
lte,
|
lte,
|
||||||
desc
|
desc
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||||
|
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||||
|
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||||
|
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||||
|
|
||||||
export default async function staffTimeRoutes(server: FastifyInstance) {
|
export default async function staffTimeRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
|
||||||
|
server.post("/staff/time/event", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id
|
||||||
|
const tenantId = req.user.tenant_id
|
||||||
|
|
||||||
|
const body = req.body as any
|
||||||
|
|
||||||
|
const normalizeDate = (val: any) => {
|
||||||
|
if (!val) return null
|
||||||
|
const d = new Date(val)
|
||||||
|
return isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToInsert = {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: userId,
|
||||||
|
actortype: "user",
|
||||||
|
actoruser_id: userId,
|
||||||
|
eventtime: normalizeDate(body.eventtime),
|
||||||
|
eventtype: body.eventtype,
|
||||||
|
source: "WEB"
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(dataToInsert)
|
||||||
|
|
||||||
|
const [created] = await server.db
|
||||||
|
.insert(stafftimeevents)
|
||||||
|
//@ts-ignore
|
||||||
|
.values(dataToInsert)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return created
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
return reply.code(400).send({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /staff/time/submit
|
||||||
|
server.post("/staff/time/submit", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id; // Mitarbeiter, der einreicht
|
||||||
|
const tenantId = req.user.tenant_id;
|
||||||
|
|
||||||
|
// Erwartet eine Liste von IDs der faktischen Events (work_start, work_end, etc.)
|
||||||
|
const { eventIds } = req.body as { eventIds: string[] };
|
||||||
|
|
||||||
|
if (eventIds.length === 0) {
|
||||||
|
return reply.code(400).send({ error: "Keine Events zum Einreichen angegeben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserts = eventIds.map((eventId) => ({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: userId, // Event gehört zum Mitarbeiter
|
||||||
|
actortype: "user",
|
||||||
|
actoruser_id: userId, // Mitarbeiter ist der Akteur
|
||||||
|
eventtime: new Date(),
|
||||||
|
eventtype: "submitted", // NEU: Event-Typ für Einreichung
|
||||||
|
source: "WEB",
|
||||||
|
related_event_id: eventId, // Verweis auf das faktische Event
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createdEvents = await server.db
|
||||||
|
.insert(stafftimeevents)
|
||||||
|
//@ts-ignore
|
||||||
|
.values(inserts)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { submittedCount: createdEvents.length };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /staff/time/approve
|
||||||
|
server.post("/staff/time/approve", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
// 🚨 Berechtigungsprüfung (Voraussetzung: req.user enthält Manager-Status)
|
||||||
|
/*if (!req.user.isManager) {
|
||||||
|
return reply.code(403).send({ error: "Keine Genehmigungsberechtigung." });
|
||||||
|
}*/
|
||||||
|
|
||||||
|
const actorId = req.user.user_id; // Manager ist der Akteur
|
||||||
|
const tenantId = req.user.tenant_id;
|
||||||
|
|
||||||
|
const { eventIds, employeeUserId } = req.body as {
|
||||||
|
eventIds: string[];
|
||||||
|
employeeUserId: string; // Die ID des Mitarbeiters, dessen Zeit genehmigt wird
|
||||||
|
};
|
||||||
|
|
||||||
|
if (eventIds.length === 0) {
|
||||||
|
return reply.code(400).send({ error: "Keine Events zur Genehmigung angegeben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserts = eventIds.map((eventId) => ({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: employeeUserId, // Event gehört zum Mitarbeiter
|
||||||
|
actortype: "user",
|
||||||
|
actoruser_id: actorId, // Manager ist der Akteur
|
||||||
|
eventtime: new Date(),
|
||||||
|
eventtype: "approved", // NEU: Event-Typ für Genehmigung
|
||||||
|
source: "WEB",
|
||||||
|
related_event_id: eventId, // Verweis auf das faktische Event
|
||||||
|
metadata: {
|
||||||
|
// Optional: Genehmigungskommentar
|
||||||
|
approvedBy: req.user.email
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createdEvents = await server.db
|
||||||
|
.insert(stafftimeevents)
|
||||||
|
//@ts-ignore
|
||||||
|
.values(inserts)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { approvedCount: createdEvents.length };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /staff/time/reject
|
||||||
|
server.post("/staff/time/reject", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
// 🚨 Berechtigungsprüfung
|
||||||
|
/*if (!req.user.isManager) {
|
||||||
|
return reply.code(403).send({ error: "Keine Zurückweisungsberechtigung." });
|
||||||
|
}*/
|
||||||
|
|
||||||
|
const actorId = req.user.user_id; // Manager ist der Akteur
|
||||||
|
const tenantId = req.user.tenant_id;
|
||||||
|
|
||||||
|
const { eventIds, employeeUserId, reason } = req.body as {
|
||||||
|
eventIds: string[];
|
||||||
|
employeeUserId: string;
|
||||||
|
reason?: string; // Optionaler Grund für die Ablehnung
|
||||||
|
};
|
||||||
|
|
||||||
|
if (eventIds.length === 0) {
|
||||||
|
return reply.code(400).send({ error: "Keine Events zur Ablehnung angegeben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserts = eventIds.map((eventId) => ({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: employeeUserId, // Event gehört zum Mitarbeiter
|
||||||
|
actortype: "user",
|
||||||
|
actoruser_id: actorId, // Manager ist der Akteur
|
||||||
|
eventtime: new Date(),
|
||||||
|
eventtype: "rejected", // NEU: Event-Typ für Ablehnung
|
||||||
|
source: "WEB",
|
||||||
|
related_event_id: eventId, // Verweis auf das faktische Event
|
||||||
|
metadata: {
|
||||||
|
reason: reason || "Ohne Angabe"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createdEvents = await server.db
|
||||||
|
.insert(stafftimeevents)
|
||||||
|
//@ts-ignore
|
||||||
|
.values(inserts)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { rejectedCount: createdEvents.length };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/staff/time/spans
|
||||||
|
server.get("/staff/time/spans", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
// Der eingeloggte User (Anfragesteller)
|
||||||
|
const actingUserId = req.user.user_id;
|
||||||
|
const tenantId = req.user.tenant_id;
|
||||||
|
|
||||||
|
// Query-Parameter: targetUserId ist optional
|
||||||
|
const { targetUserId } = req.query as { targetUserId?: string };
|
||||||
|
|
||||||
|
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
|
||||||
|
const evaluatedUserId = targetUserId || actingUserId;
|
||||||
|
|
||||||
|
// 💡 "Unendlicher" Zeitraum, wie gewünscht
|
||||||
|
const startDate = new Date(0); // 1970
|
||||||
|
const endDate = new Date("2100-12-31");
|
||||||
|
|
||||||
|
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
|
||||||
|
const allEventsInTimeFrame = await loadValidEvents(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// SCHRITT 2: Filtere faktische Events
|
||||||
|
const FACTUAL_EVENT_TYPES = new Set([
|
||||||
|
"work_start", "work_end", "pause_start", "pause_end",
|
||||||
|
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||||
|
"overtime_compensation_start", "overtime_compensation_end",
|
||||||
|
"auto_stop"
|
||||||
|
]);
|
||||||
|
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||||
|
|
||||||
|
// SCHRITT 3: Hole administrative Events
|
||||||
|
const factualEventIds = factualEvents.map(e => e.id);
|
||||||
|
|
||||||
|
if (factualEventIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||||
|
|
||||||
|
// SCHRITT 4: Kombinieren und Sortieren
|
||||||
|
const combinedEvents = [
|
||||||
|
...factualEvents,
|
||||||
|
...relatedAdminEvents,
|
||||||
|
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||||
|
|
||||||
|
// SCHRITT 5: Spans ableiten
|
||||||
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
|
|
||||||
|
// SCHRITT 6: Spans anreichern
|
||||||
|
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||||
|
|
||||||
|
return enrichedSpans;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Laden der Spans:", error);
|
||||||
|
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get("/staff/time/evaluation", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
// --- 1. Eingangsdaten und Validierung des aktuellen Nutzers ---
|
||||||
|
|
||||||
|
// Daten des aktuell eingeloggten (anfragenden) Benutzers
|
||||||
|
const actingUserId = req.user.user_id;
|
||||||
|
const tenantId = req.user.tenant_id;
|
||||||
|
|
||||||
|
// Query-Parameter extrahieren
|
||||||
|
const { from, to, targetUserId } = req.query as {
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
targetUserId?: string // Optionale ID des Benutzers, dessen Daten abgerufen werden sollen
|
||||||
|
};
|
||||||
|
|
||||||
|
// Die ID, für die die Auswertung tatsächlich durchgeführt wird
|
||||||
|
const evaluatedUserId = targetUserId || actingUserId;
|
||||||
|
|
||||||
|
const startDate = new Date(from);
|
||||||
|
const endDate = new Date(to);
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return reply.code(400).send({ error: "Ungültiges Datumsformat." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Ausführung der Logik für den ermittelten Benutzer ---
|
||||||
|
|
||||||
|
// SCHRITT 1: Lade ALLE gültigen Events im Zeitraum
|
||||||
|
const allEventsInTimeFrame = await loadValidEvents(
|
||||||
|
server, tenantId, evaluatedUserId, startDate, endDate // Verwendung der evaluatedUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1b: Trenne Faktische und Administrative Events
|
||||||
|
const FACTUAL_EVENT_TYPES = new Set([
|
||||||
|
"work_start", "work_end", "pause_start", "pause_end",
|
||||||
|
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||||
|
"overtime_compensation_start", "overtime_compensation_end",
|
||||||
|
"auto_stop"
|
||||||
|
]);
|
||||||
|
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||||
|
|
||||||
|
// 1c: Sammle alle IDs der faktischen Events im Zeitraum
|
||||||
|
const factualEventIds = factualEvents.map(e => e.id);
|
||||||
|
|
||||||
|
// SCHRITT 2: Lade die administrativen Events, die sich auf diese IDs beziehen (auch NACH dem Zeitraum)
|
||||||
|
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||||
|
|
||||||
|
// SCHRITT 3: Kombiniere alle Events für die Weiterverarbeitung
|
||||||
|
const combinedEvents = [
|
||||||
|
...factualEvents,
|
||||||
|
...relatedAdminEvents,
|
||||||
|
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||||
|
|
||||||
|
// SCHRITT 4: Ableiten und Anreichern
|
||||||
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
|
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||||
|
|
||||||
|
// SCHRITT 5: Erstellung der finalen Auswertung (Summen und Salden)
|
||||||
|
const evaluationSummary = await buildTimeEvaluationFromSpans(
|
||||||
|
server,
|
||||||
|
evaluatedUserId, // Verwendung der evaluatedUserId
|
||||||
|
tenantId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enrichedSpans
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: evaluatedUserId, // Rückgabe der ID, für die ausgewertet wurde
|
||||||
|
spans: enrichedSpans,
|
||||||
|
summary: evaluationSummary
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler in /staff/time/evaluation:", error);
|
||||||
|
return reply.code(500).send({ error: "Interner Serverfehler bei der Zeitauswertung." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*// -------------------------------------------------------------
|
||||||
// ▶ Neue Zeit starten
|
// ▶ Neue Zeit starten
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
server.post("/staff/time", async (req, reply) => {
|
server.post("/staff/time", async (req, reply) => {
|
||||||
@@ -232,5 +556,5 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message })
|
return reply.code(400).send({ error: (err as Error).message })
|
||||||
}
|
}
|
||||||
})
|
})*/
|
||||||
}
|
}
|
||||||
|
|||||||
198
src/utils/pdf.ts
198
src/utils/pdf.ts
@@ -878,11 +878,10 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
|
|||||||
let pages = []
|
let pages = []
|
||||||
let pageCounter = 1
|
let pageCounter = 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const backgroudPdf = await PDFDocument.load(backgroundSourceBuffer)
|
const backgroudPdf = await PDFDocument.load(backgroundSourceBuffer)
|
||||||
|
|
||||||
const firstPageBackground = await pdfDoc.embedPage(backgroudPdf.getPages()[0])
|
const firstPageBackground = await pdfDoc.embedPage(backgroudPdf.getPages()[0])
|
||||||
|
// Fallback für einseitige Hintergründe
|
||||||
const secondPageBackground = await pdfDoc.embedPage(backgroudPdf.getPages()[backgroudPdf.getPages().length > 1 ? 1 : 0])
|
const secondPageBackground = await pdfDoc.embedPage(backgroudPdf.getPages()[backgroudPdf.getPages().length > 1 ? 1 : 0])
|
||||||
|
|
||||||
const page1 = pdfDoc.addPage()
|
const page1 = pdfDoc.addPage()
|
||||||
@@ -894,121 +893,145 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
|
|||||||
|
|
||||||
pages.push(page1)
|
pages.push(page1)
|
||||||
|
|
||||||
|
console.log("PDF Input Data:", input)
|
||||||
|
|
||||||
//Falzmarke 1
|
// ---------------------------------------------------------
|
||||||
/*pages[pageCounter - 1].drawLine({
|
// DATEN-EXTRAKTION MIT FALLBACKS (Calculated vs. Standard)
|
||||||
start: getCoordinatesForPDFLib(0,105,page1),
|
// ---------------------------------------------------------
|
||||||
end: getCoordinatesForPDFLib(7,105,page1),
|
|
||||||
thickness: 0.25,
|
|
||||||
color: rgb(0,0,0),
|
|
||||||
opacity: 1
|
|
||||||
})*/
|
|
||||||
|
|
||||||
//Lochmarke
|
// Summen: Bevorzuge calculated..., falls vorhanden
|
||||||
/*pages[pageCounter - 1].drawLine({
|
const sumSubmitted = input.calculatedSumWorkingMinutesSubmitted ?? input.sumWorkingMinutesSubmitted ?? 0;
|
||||||
start: getCoordinatesForPDFLib(0,148.5,page1),
|
const sumApproved = input.calculatedSumWorkingMinutesApproved ?? input.sumWorkingMinutesApproved ?? 0;
|
||||||
end: getCoordinatesForPDFLib(7,148.5,page1),
|
|
||||||
thickness: 0.25,
|
// Saldi: Bevorzuge calculated...
|
||||||
color: rgb(0,0,0),
|
const saldoSubmitted = input.calculatedSaldoSubmitted ?? input.saldoSubmitted ?? input.saldoInOfficial ?? 0;
|
||||||
opacity: 1
|
const saldoApproved = input.calculatedSaldoApproved ?? input.saldoApproved ?? input.saldo ?? 0;
|
||||||
})*/
|
|
||||||
|
// Andere Summen (diese sind meist korrekt vom Backend)
|
||||||
|
const sumRecreation = input.sumWorkingMinutesRecreationDays ?? 0;
|
||||||
|
const sumVacation = input.sumWorkingMinutesVacationDays ?? 0;
|
||||||
|
const sumSick = input.sumWorkingMinutesSickDays ?? 0;
|
||||||
|
const sumTarget = input.timeSpanWorkingMinutes ?? 0;
|
||||||
|
|
||||||
|
// Hilfsfunktion zur Formatierung von Minuten -> HH:MM
|
||||||
|
const fmtTime = (mins) => {
|
||||||
|
const m = Math.floor(Math.abs(mins));
|
||||||
|
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtSaldo = (mins) => {
|
||||||
|
const sign = Math.sign(mins) >= 0 ? "+" : "-";
|
||||||
|
return `${sign} ${fmtTime(mins)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// HEADER TEXTE ZEICHNEN
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
//Falzmarke 2
|
|
||||||
/*pages[pageCounter - 1].drawLine({
|
|
||||||
start: getCoordinatesForPDFLib(0,210,page1),
|
|
||||||
end: getCoordinatesForPDFLib(7,210,page1),
|
|
||||||
thickness: 0.25,
|
|
||||||
color: rgb(0,0,0),
|
|
||||||
opacity: 1
|
|
||||||
})*/
|
|
||||||
console.log(input)
|
|
||||||
pages[pageCounter - 1].drawText(`Anwesenheitsauswertung`,{
|
pages[pageCounter - 1].drawText(`Anwesenheitsauswertung`,{
|
||||||
x: getCoordinatesForPDFLib(20,40,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,60,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,40,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,60,pages[pageCounter -1]).y,
|
||||||
size: 15,
|
size: 15,
|
||||||
font: fontBold
|
font: fontBold
|
||||||
})
|
})
|
||||||
|
|
||||||
pages[pageCounter - 1].drawText(`Mitarbeiter: ${input.full_name}`,{
|
pages[pageCounter - 1].drawText(`Mitarbeiter: ${input.full_name || ''}`,{
|
||||||
x: getCoordinatesForPDFLib(20,50,pages[pageCounter -1]).x,
|
|
||||||
y: getCoordinatesForPDFLib(20,50,pages[pageCounter -1]).y,
|
|
||||||
size: 10,
|
|
||||||
})
|
|
||||||
pages[pageCounter - 1].drawText(`Nummer: ${input.employee_number}`,{
|
|
||||||
x: getCoordinatesForPDFLib(20,55,pages[pageCounter -1]).x,
|
|
||||||
y: getCoordinatesForPDFLib(20,55,pages[pageCounter -1]).y,
|
|
||||||
size: 10,
|
|
||||||
})
|
|
||||||
pages[pageCounter - 1].drawText(`Eingereicht: ${Math.floor(input.sumWorkingMinutesEingereicht/60)}:${String(input.sumWorkingMinutesEingereicht % 60).padStart(2,"0")} Std`,{
|
|
||||||
x: getCoordinatesForPDFLib(20,60,pages[pageCounter -1]).x,
|
|
||||||
y: getCoordinatesForPDFLib(20,60,pages[pageCounter -1]).y,
|
|
||||||
size: 10,
|
|
||||||
})
|
|
||||||
pages[pageCounter - 1].drawText(`Genehmigt: ${Math.floor(input.sumWorkingMinutesApproved/60)}:${String(input.sumWorkingMinutesApproved % 60).padStart(2,"0")} Std`,{
|
|
||||||
x: getCoordinatesForPDFLib(20,65,pages[pageCounter -1]).x,
|
|
||||||
y: getCoordinatesForPDFLib(20,65,pages[pageCounter -1]).y,
|
|
||||||
size: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
pages[pageCounter - 1].drawText(`Feiertagsausgleich: ${Math.floor(input.sumWorkingMinutesRecreationDays/60)}:${String(input.sumWorkingMinutesRecreationDays % 60).padStart(2,"0")} Std`,{
|
|
||||||
x: getCoordinatesForPDFLib(20,70,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,70,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,70,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,70,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
pages[pageCounter - 1].drawText(`Urlaubsausgleich: ${Math.floor(input.sumWorkingMinutesVacationDays/60)}:${String(input.sumWorkingMinutesVacationDays % 60).padStart(2,"0")} Std`,{
|
pages[pageCounter - 1].drawText(`Nummer: ${input.employee_number || '-'}`,{
|
||||||
x: getCoordinatesForPDFLib(20,75,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,75,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,75,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,75,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
pages[pageCounter - 1].drawText(`Krankheitsausgleich: ${Math.floor(input.sumWorkingMinutesSickDays/60)}:${String(input.sumWorkingMinutesSickDays % 60).padStart(2,"0")} Std`,{
|
|
||||||
|
// Zeile 1: Eingereicht & Genehmigt
|
||||||
|
pages[pageCounter - 1].drawText(`Eingereicht: ${fmtTime(sumSubmitted)} Std`,{
|
||||||
x: getCoordinatesForPDFLib(20,80,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,80,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,80,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,80,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
pages[pageCounter - 1].drawText(`Soll Stunden: ${Math.floor(input.timeSpanWorkingMinutes/60)}:${Math.floor(Number(String(input.timeSpanWorkingMinutes % 60).padStart(2,"0")))} Std`,{
|
pages[pageCounter - 1].drawText(`Genehmigt: ${fmtTime(sumApproved)} Std`,{
|
||||||
x: getCoordinatesForPDFLib(20,85,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,85,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,85,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,85,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
pages[pageCounter - 1].drawText(`Inoffizielles Saldo: ${Math.sign(input.saldoInOfficial) === 1 ? "+" : "-"} ${Math.floor(Math.abs(input.saldoInOfficial/60))}:${Math.floor(Number(String(Math.abs(input.saldoInOfficial) % 60).padStart(2,"0")))} Std`,{
|
|
||||||
|
// Zeile 2: Ausgleichstage
|
||||||
|
pages[pageCounter - 1].drawText(`Feiertagsausgleich: ${fmtTime(sumRecreation)} Std`,{
|
||||||
x: getCoordinatesForPDFLib(20,90,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,90,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,90,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,90,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
pages[pageCounter - 1].drawText(`Saldo: ${Math.sign(input.saldo) === 1 ? "+" : "-"} ${Math.floor(Math.abs(input.saldo/60))}:${Math.floor(Number(String(Math.abs(input.saldo) % 60).padStart(2,"0")))} Std`,{
|
pages[pageCounter - 1].drawText(`Urlaubsausgleich: ${fmtTime(sumVacation)} Std`,{
|
||||||
x: getCoordinatesForPDFLib(20,95,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,95,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,95,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,95,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
pages[pageCounter - 1].drawText(`Krankheitsausgleich: ${fmtTime(sumSick)} Std`,{
|
||||||
pages[pageCounter - 1].drawText(`Start:`,{
|
|
||||||
x: getCoordinatesForPDFLib(20,100,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,100,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,100,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,100,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Zeile 3: Soll & Saldo
|
||||||
|
pages[pageCounter - 1].drawText(`Soll Stunden: ${fmtTime(sumTarget)} Std`,{
|
||||||
|
x: getCoordinatesForPDFLib(20,105,pages[pageCounter -1]).x,
|
||||||
|
y: getCoordinatesForPDFLib(20,105,pages[pageCounter -1]).y,
|
||||||
|
size: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wir nutzen hier die Begriffe "Inoffiziell" (Submitted Saldo) und "Saldo" (Approved Saldo)
|
||||||
|
pages[pageCounter - 1].drawText(`Inoffizielles Saldo: ${fmtSaldo(saldoSubmitted)} Std`,{
|
||||||
|
x: getCoordinatesForPDFLib(20,110,pages[pageCounter -1]).x,
|
||||||
|
y: getCoordinatesForPDFLib(20,110,pages[pageCounter -1]).y,
|
||||||
|
size: 10,
|
||||||
|
})
|
||||||
|
pages[pageCounter - 1].drawText(`Saldo: ${fmtSaldo(saldoApproved)} Std`,{
|
||||||
|
x: getCoordinatesForPDFLib(20,115,pages[pageCounter -1]).x,
|
||||||
|
y: getCoordinatesForPDFLib(20,115,pages[pageCounter -1]).y,
|
||||||
|
size: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tabellen-Header
|
||||||
|
pages[pageCounter - 1].drawText(`Start:`,{
|
||||||
|
x: getCoordinatesForPDFLib(20,125,pages[pageCounter -1]).x,
|
||||||
|
y: getCoordinatesForPDFLib(20,125,pages[pageCounter -1]).y,
|
||||||
|
size: 10,
|
||||||
|
})
|
||||||
|
|
||||||
pages[pageCounter - 1].drawText(`Ende:`,{
|
pages[pageCounter - 1].drawText(`Ende:`,{
|
||||||
x: getCoordinatesForPDFLib(60,100,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(60,125,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(60,100,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(60,125,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
pages[pageCounter - 1].drawText(`Dauer:`,{
|
pages[pageCounter - 1].drawText(`Dauer:`,{
|
||||||
x: getCoordinatesForPDFLib(100,100,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(100,125,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(100,100,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(100,125,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// TABELLE GENERIEREN (Spans verarbeiten)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
let rowHeight = 115
|
let rowHeight = 130
|
||||||
|
|
||||||
|
// WICHTIG: input.spans verwenden, fallback auf input.times (altes Format)
|
||||||
|
// Wir filtern leere Einträge raus
|
||||||
|
const rawItems = (input.spans || input.times || []).filter(t => t);
|
||||||
|
|
||||||
|
// Sortierung umkehren (neueste zuletzt für den Druck? Oder wie gewünscht)
|
||||||
|
// Im Original war es .reverse().
|
||||||
|
let reversedInput = rawItems.slice().reverse();
|
||||||
|
|
||||||
let splitted = []
|
let splitted = []
|
||||||
|
|
||||||
let reversedInput = input.times.slice().reverse()
|
|
||||||
|
|
||||||
const splittedLength = Math.floor((reversedInput.length - 25) / 40)
|
const splittedLength = Math.floor((reversedInput.length - 25) / 40)
|
||||||
|
|
||||||
|
// Erste Seite hat weniger Platz wegen Header (25 Zeilen)
|
||||||
splitted.push(reversedInput.slice(0,25))
|
splitted.push(reversedInput.slice(0,25))
|
||||||
|
|
||||||
let lastIndex = 25
|
let lastIndex = 25
|
||||||
@@ -1017,10 +1040,11 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
|
|||||||
lastIndex = lastIndex + (i + 1) * 40 + 1
|
lastIndex = lastIndex + (i + 1) * 40 + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if(reversedInput.slice(lastIndex, reversedInput.length).length > 0) splitted.push(reversedInput.slice(lastIndex, reversedInput.length))
|
if(reversedInput.slice(lastIndex, reversedInput.length).length > 0) {
|
||||||
|
splitted.push(reversedInput.slice(lastIndex, reversedInput.length))
|
||||||
console.log(splitted )
|
}
|
||||||
|
|
||||||
|
console.log("PDF Pages Chunks:", splitted.length)
|
||||||
|
|
||||||
splitted.forEach((chunk,index) => {
|
splitted.forEach((chunk,index) => {
|
||||||
if(index > 0) {
|
if(index > 0) {
|
||||||
@@ -1034,30 +1058,50 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
|
|||||||
pages.push(page)
|
pages.push(page)
|
||||||
pageCounter++
|
pageCounter++
|
||||||
rowHeight = 20
|
rowHeight = 20
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chunk.forEach(time => {
|
chunk.forEach(span => {
|
||||||
pages[pageCounter - 1].drawText(`${dayjs(time.started_at).format("HH:mm DD.MM.YY")}`,{
|
// Mapping für Felder: spans nutzen 'startedAt', times nutzten 'started_at'
|
||||||
|
const startStr = span.startedAt || span.started_at;
|
||||||
|
const endStr = span.endedAt || span.stopped_at; // endedAt oder stopped_at
|
||||||
|
|
||||||
|
// Dauer berechnen (da Spans keine duration_minutes haben)
|
||||||
|
let durationStr = "";
|
||||||
|
if (startStr && endStr) {
|
||||||
|
const diffMins = dayjs(endStr).diff(dayjs(startStr), 'minute');
|
||||||
|
durationStr = fmtTime(diffMins);
|
||||||
|
} else if (span.duration_minutes) {
|
||||||
|
durationStr = fmtTime(span.duration_minutes);
|
||||||
|
} else if (span.duration) { // Falls schon formatiert übergeben
|
||||||
|
durationStr = span.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
pages[pageCounter - 1].drawText(`${dayjs(startStr).format("HH:mm DD.MM.YY")}`,{
|
||||||
x: getCoordinatesForPDFLib(20,rowHeight,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(20,rowHeight,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(20,rowHeight,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(20,rowHeight,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
pages[pageCounter - 1].drawText(`${dayjs(time.stopped_at).format("HH:mm DD.MM.YY")}`,{
|
pages[pageCounter - 1].drawText(`${endStr ? dayjs(endStr).format("HH:mm DD.MM.YY") : 'läuft...'}`,{
|
||||||
x: getCoordinatesForPDFLib(60,rowHeight,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(60,rowHeight,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(60,rowHeight,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(60,rowHeight,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
pages[pageCounter - 1].drawText(`${getDuration(time).composed}`,{
|
pages[pageCounter - 1].drawText(`${durationStr}`,{
|
||||||
x: getCoordinatesForPDFLib(100,rowHeight,pages[pageCounter -1]).x,
|
x: getCoordinatesForPDFLib(100,rowHeight,pages[pageCounter -1]).x,
|
||||||
y: getCoordinatesForPDFLib(100,rowHeight,pages[pageCounter -1]).y,
|
y: getCoordinatesForPDFLib(100,rowHeight,pages[pageCounter -1]).y,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
rowHeight += 6
|
// Optional: Status anzeigen?
|
||||||
|
/*pages[pageCounter - 1].drawText(`${span.status || span.state || ''}`,{
|
||||||
|
x: getCoordinatesForPDFLib(130,rowHeight,pages[pageCounter -1]).x,
|
||||||
|
y: getCoordinatesForPDFLib(130,rowHeight,pages[pageCounter -1]).y,
|
||||||
|
size: 8,
|
||||||
|
})*/
|
||||||
|
|
||||||
|
rowHeight += 6
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1077,8 +1121,6 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
|
|||||||
}
|
}
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
throw error; // Fehler weiterwerfen, damit er oben ankommt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user