diff --git a/db/schema/staff_time_events.ts b/db/schema/staff_time_events.ts new file mode 100644 index 0000000..d58f77c --- /dev/null +++ b/db/schema/staff_time_events.ts @@ -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) + ` + ), + }) +); diff --git a/src/modules/time/buildtimeevaluation.service.ts b/src/modules/time/buildtimeevaluation.service.ts new file mode 100644 index 0000000..e420e7c --- /dev/null +++ b/src/modules/time/buildtimeevaluation.service.ts @@ -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 { + + 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, + }; +} \ No newline at end of file diff --git a/src/modules/time/derivetimespans.service.ts b/src/modules/time/derivetimespans.service.ts new file mode 100644 index 0000000..2c18f25 --- /dev/null +++ b/src/modules/time/derivetimespans.service.ts @@ -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; +} \ No newline at end of file diff --git a/src/modules/time/enrichtimespanswithstatus.service.ts b/src/modules/time/enrichtimespanswithstatus.service.ts new file mode 100644 index 0000000..b82f2eb --- /dev/null +++ b/src/modules/time/enrichtimespanswithstatus.service.ts @@ -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(); + + 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, + }; + }); +} \ No newline at end of file diff --git a/src/modules/time/loadvalidevents.service.ts b/src/modules/time/loadvalidevents.service.ts new file mode 100644 index 0000000..134e363 --- /dev/null +++ b/src/modules/time/loadvalidevents.service.ts @@ -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 { + // 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; +} \ No newline at end of file diff --git a/src/routes/staff/time.ts b/src/routes/staff/time.ts index 5b4956c..33f57c1 100644 --- a/src/routes/staff/time.ts +++ b/src/routes/staff/time.ts @@ -10,10 +10,334 @@ import { 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 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 // ------------------------------------------------------------- server.post("/staff/time", async (req, reply) => { @@ -232,5 +556,5 @@ export default async function staffTimeRoutes(server: FastifyInstance) { } catch (err) { return reply.code(400).send({ error: (err as Error).message }) } - }) + })*/ }