diff --git a/backend/src/modules/time/derivetimespans.service.ts b/backend/src/modules/time/derivetimespans.service.ts index 2c18f25..3db593a 100644 --- a/backend/src/modules/time/derivetimespans.service.ts +++ b/backend/src/modules/time/derivetimespans.service.ts @@ -9,12 +9,15 @@ export type DerivedSpan = { sourceEventIds: string[]; status: SpanStatus; statusActorId?: string; + payload?: Record | null; + description?: string; }; type TimeEvent = { id: string; eventtype: string; eventtime: Date; + payload?: Record | null; }; // Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen @@ -45,9 +48,17 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] { let currentStart: Date | null = null; let currentType: DerivedSpan["type"] | null = null; let sourceEventIds: string[] = []; + let currentPayload: Record | null = null; const closeSpan = (end: Date) => { if (!currentStart || !currentType) return; + if (end.getTime() <= currentStart.getTime()) { + currentStart = null; + currentType = null; + sourceEventIds = []; + currentPayload = null; + return; + } spans.push({ type: currentType, @@ -55,12 +66,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] { endedAt: end, sourceEventIds: [...sourceEventIds], // Standardstatus ist "factual", wird später angereichert - status: "factual" + status: "factual", + payload: currentPayload, + description: currentPayload?.description || "" }); currentStart = null; currentType = null; sourceEventIds = []; + currentPayload = null; }; const closeOpenSpanAsRunning = () => { @@ -72,12 +86,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] { endedAt: null, sourceEventIds: [...sourceEventIds], // Standardstatus ist "factual", wird später angereichert - status: "factual" + status: "factual", + payload: currentPayload, + description: currentPayload?.description || "" }); currentStart = null; currentType = null; sourceEventIds = []; + currentPayload = null; }; for (const event of events) { @@ -96,6 +113,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] { state = "WORKING"; currentStart = event.eventtime; currentType = "work"; + currentPayload = event.payload || null; break; case "pause_start": @@ -104,6 +122,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] { state = "PAUSED"; currentStart = event.eventtime; currentType = "pause"; + currentPayload = event.payload || null; } break; @@ -113,6 +132,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] { state = "WORKING"; currentStart = event.eventtime; currentType = "work"; + currentPayload = event.payload || null; } break; @@ -140,6 +160,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] { state = "ABSENT"; currentStart = event.eventtime; currentType = newType; + currentPayload = event.payload || null; break; case "vacation_end": @@ -162,4 +183,4 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] { } return spans; -} \ No newline at end of file +} diff --git a/backend/src/modules/time/loadvalidevents.service.ts b/backend/src/modules/time/loadvalidevents.service.ts index 134e363..dc8e99c 100644 --- a/backend/src/modules/time/loadvalidevents.service.ts +++ b/backend/src/modules/time/loadvalidevents.service.ts @@ -1,7 +1,7 @@ // src/services/loadValidEvents.ts import { stafftimeevents } from "../../../db/schema"; -import {sql, and, eq, gte, lte, inArray} from "drizzle-orm"; +import {sql, and, eq, gte, lte, inArray, asc} 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"; @@ -12,11 +12,43 @@ export type TimeEvent = { id: string; eventtype: string; eventtime: Date; - actoruser_id: string; + actoruser_id?: string; related_event_id: string | null; - // Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen + payload?: Record | null; + created_at?: Date | null; }; +const EVENT_TYPE_ORDER: Record = { + auto_stop: 10, + work_end: 10, + pause_end: 10, + vacation_end: 10, + sick_end: 10, + overtime_compensation_end: 10, + work_start: 20, + pause_start: 20, + vacation_start: 20, + sick_start: 20, + overtime_compensation_start: 20, + submitted: 30, + approved: 30, + rejected: 30, + invalidated: 40, +}; + +export function compareTimeEvents(a: TimeEvent, b: TimeEvent) { + const eventTimeDiff = a.eventtime.getTime() - b.eventtime.getTime(); + if (eventTimeDiff !== 0) return eventTimeDiff; + + const typeOrderDiff = (EVENT_TYPE_ORDER[a.eventtype] ?? 999) - (EVENT_TYPE_ORDER[b.eventtype] ?? 999); + if (typeOrderDiff !== 0) return typeOrderDiff; + + const createdAtDiff = (a.created_at?.getTime() ?? 0) - (b.created_at?.getTime() ?? 0); + if (createdAtDiff !== 0) return createdAtDiff; + + return a.id.localeCompare(b.id); +} + export async function loadValidEvents( server: FastifyInstance, tenantId: number, @@ -62,10 +94,9 @@ export async function loadValidEvents( ) ) .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 + asc(baseEvents.eventtime), + asc(baseEvents.created_at), + asc(baseEvents.id) ); // Mapping auf den sauberen TimeEvent Typ @@ -73,8 +104,10 @@ export async function loadValidEvents( id: e.id, eventtype: e.eventtype, eventtime: e.eventtime, - // Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id) - // ... + actoruser_id: e.actoruser_id, + related_event_id: e.related_event_id, + payload: e.payload, + created_at: e.created_at, })) as TimeEvent[]; } @@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) { ) ) // Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen! - .orderBy(stafftimeevents.eventtime); + .orderBy( + asc(stafftimeevents.eventtime), + asc(stafftimeevents.created_at), + asc(stafftimeevents.id) + ); - return adminEvents; -} \ No newline at end of file + return adminEvents as TimeEvent[]; +} diff --git a/backend/src/routes/internal/time.ts b/backend/src/routes/internal/time.ts index 89b0544..4f83b52 100644 --- a/backend/src/routes/internal/time.ts +++ b/backend/src/routes/internal/time.ts @@ -11,7 +11,7 @@ import { desc } from "drizzle-orm" import {stafftimeevents} from "../../../db/schema/staff_time_events"; -import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service"; +import {compareTimeEvents, 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"; @@ -102,7 +102,7 @@ export default async function staffTimeRoutesInternal(server: FastifyInstance) { const combinedEvents = [ ...factualEvents, ...relatedAdminEvents, - ].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); + ].sort(compareTimeEvents); // SCHRITT 5: Spans ableiten const derivedSpans = deriveTimeSpans(combinedEvents); diff --git a/backend/src/routes/staff/time.ts b/backend/src/routes/staff/time.ts index 7232795..80ef444 100644 --- a/backend/src/routes/staff/time.ts +++ b/backend/src/routes/staff/time.ts @@ -11,7 +11,7 @@ import { desc } from "drizzle-orm" import {stafftimeevents} from "../../../db/schema/staff_time_events"; -import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service"; +import {compareTimeEvents, 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"; @@ -354,7 +354,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) { const combinedEvents = [ ...factualEvents, ...relatedAdminEvents, - ].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); + ].sort(compareTimeEvents); // SCHRITT 5: Spans ableiten const derivedSpans = deriveTimeSpans(combinedEvents); @@ -424,7 +424,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) { const combinedEvents = [ ...factualEvents, ...relatedAdminEvents, - ].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); + ].sort(compareTimeEvents); // SCHRITT 4: Ableiten und Anreichern const derivedSpans = deriveTimeSpans(combinedEvents); @@ -453,4 +453,4 @@ export default async function staffTimeRoutes(server: FastifyInstance) { } }); -} \ No newline at end of file +} diff --git a/frontend/components/StaffTimeEntryModal.vue b/frontend/components/StaffTimeEntryModal.vue index d89932d..5277b8b 100644 --- a/frontend/components/StaffTimeEntryModal.vue +++ b/frontend/components/StaffTimeEntryModal.vue @@ -12,7 +12,7 @@ const props = defineProps({ const emit = defineEmits(['update:modelValue', 'saved']) // 💡 createEntry importieren -const { update, createEntry } = useStaffTime() +const { list, update, createEntry } = useStaffTime() const { $dayjs } = useNuxtApp() const toast = useToast() @@ -85,7 +85,7 @@ async function onSubmit(event: FormSubmitEvent) { if (state.end_date && state.end_time) { endIso = $dayjs(`${state.end_date} ${state.end_time}`).toISOString() - if ($dayjs(endIso).isBefore($dayjs(startIso))) { + if (!$dayjs(endIso).isAfter($dayjs(startIso))) { throw new Error("Endzeitpunkt muss nach dem Startzeitpunkt liegen.") } } @@ -100,6 +100,33 @@ async function onSubmit(event: FormSubmitEvent) { }) toast.add({ title: 'Eintrag aktualisiert', color: 'green' }) } else { + if (endIso) { + const existingEntries = await list({ + user_id: props.defaultUserId + }) + + const newStart = $dayjs(startIso).valueOf() + const newEnd = $dayjs(endIso).valueOf() + const blockingStates = new Set(['draft', 'factual', 'submitted', 'approved']) + + const conflictingEntry = existingEntries.find(existingEntry => { + if (!blockingStates.has(existingEntry.state) || !existingEntry.stopped_at) return false + + const existingStart = $dayjs(existingEntry.started_at).valueOf() + const existingEnd = $dayjs(existingEntry.stopped_at).valueOf() + + if (!Number.isFinite(existingStart) || !Number.isFinite(existingEnd)) return false + + return newStart < existingEnd && existingStart < newEnd + }) + + if (conflictingEntry) { + const conflictStart = $dayjs(conflictingEntry.started_at).format('DD.MM.YYYY HH:mm') + const conflictEnd = $dayjs(conflictingEntry.stopped_at).format('DD.MM.YYYY HH:mm') + throw new Error(`Überschneidung mit ${conflictStart} bis ${conflictEnd} (${conflictingEntry.state}).`) + } + } + // 🟢 CREATE (Neu Erstellen) // 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular await createEntry({ diff --git a/frontend/composables/useStaffTime.ts b/frontend/composables/useStaffTime.ts index b6e6d67..3261a4b 100644 --- a/frontend/composables/useStaffTime.ts +++ b/frontend/composables/useStaffTime.ts @@ -26,7 +26,7 @@ export const useStaffTime = () => { duration_minutes: end.diff(start, 'minute'), user_id: targetUserId, type: span.type, - description: span.payload?.description || '' + description: span.description || span.payload?.description || '' } }).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf()) } catch (error) { @@ -126,4 +126,4 @@ export const useStaffTime = () => { } return { list, start, stop, submit, approve, reject, update, createEntry } -} \ No newline at end of file +} diff --git a/frontend/pages/staff/time/index.vue b/frontend/pages/staff/time/index.vue index 63bd89d..82aba34 100644 --- a/frontend/pages/staff/time/index.vue +++ b/frontend/pages/staff/time/index.vue @@ -272,7 +272,7 @@ onMounted(async () => { - + @@ -366,7 +366,7 @@ onMounted(async () => { /> @@ -434,6 +434,7 @@ onMounted(async () => {
+