feat(time): add span-based attendance tracking under 2.0
This commit is contained in:
93
backend/db/migrations/0080_staff_time_spans.sql
Normal file
93
backend/db/migrations/0080_staff_time_spans.sql
Normal file
@@ -0,0 +1,93 @@
|
||||
CREATE TABLE staff_time_v2_accounts (
|
||||
tenant_id bigint NOT NULL REFERENCES tenants(id), user_id uuid NOT NULL REFERENCES auth_users(id),
|
||||
activated_at timestamptz NOT NULL DEFAULT now(), activated_by uuid REFERENCES auth_users(id),
|
||||
PRIMARY KEY(tenant_id,user_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE staff_time_spans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id bigint NOT NULL REFERENCES tenants(id),
|
||||
user_id uuid NOT NULL REFERENCES auth_users(id),
|
||||
type text NOT NULL CHECK(type IN ('work','vacation','sick','overtime_compensation','holiday','other')),
|
||||
started_at timestamptz NOT NULL, ended_at timestamptz,
|
||||
date_from date, date_to date, timezone text NOT NULL DEFAULT 'Europe/Berlin',
|
||||
description text NOT NULL DEFAULT '', source text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'factual' CHECK(status IN ('factual','submitted','approved','rejected')),
|
||||
version integer NOT NULL DEFAULT 1 CHECK(version > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK(ended_at IS NULL OR ended_at > started_at),
|
||||
CHECK((date_from IS NULL AND date_to IS NULL) OR (type <> 'work' AND date_from IS NOT NULL AND date_to IS NOT NULL AND date_to > date_from AND ended_at IS NOT NULL)),
|
||||
CHECK(status = 'factual' OR ended_at IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX staff_time_spans_user_time ON staff_time_spans(tenant_id,user_id,started_at);
|
||||
CREATE UNIQUE INDEX staff_time_spans_one_open ON staff_time_spans(tenant_id,user_id) WHERE ended_at IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE staff_time_span_breaks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
|
||||
started_at timestamptz NOT NULL, ended_at timestamptz,
|
||||
CHECK(ended_at IS NULL OR ended_at > started_at)
|
||||
);
|
||||
CREATE INDEX staff_time_span_breaks_parent ON staff_time_span_breaks(span_id);
|
||||
CREATE UNIQUE INDEX staff_time_span_breaks_one_open ON staff_time_span_breaks(span_id) WHERE ended_at IS NULL;
|
||||
CREATE TABLE staff_time_span_projects (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
|
||||
project_id bigint NOT NULL REFERENCES projects(id), started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL,
|
||||
notes text NOT NULL DEFAULT '', CHECK(ended_at > started_at)
|
||||
);
|
||||
CREATE INDEX staff_time_span_projects_parent ON staff_time_span_projects(span_id);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE staff_time_span_history (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
|
||||
actor_id uuid REFERENCES auth_users(id), action text NOT NULL, reason text NOT NULL DEFAULT '',
|
||||
before_data jsonb, after_data jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX staff_time_span_history_parent ON staff_time_span_history(span_id,created_at);
|
||||
CREATE TABLE staff_time_v2_requests (
|
||||
tenant_id bigint NOT NULL, user_id uuid NOT NULL, request_id text NOT NULL,
|
||||
fingerprint text NOT NULL, response jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(tenant_id,user_id,request_id)
|
||||
);
|
||||
CREATE TABLE staff_time_span_origins (
|
||||
tenant_id bigint NOT NULL, source_kind text NOT NULL, source_id uuid NOT NULL,
|
||||
span_id uuid NOT NULL REFERENCES staff_time_spans(id), PRIMARY KEY(tenant_id,source_kind,source_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
-- The same lock as the service makes cutover atomic with concurrent legacy writers.
|
||||
CREATE FUNCTION staff_time_legacy_write_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE t bigint; u uuid;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN t := OLD.tenant_id; u := OLD.user_id;
|
||||
ELSE t := NEW.tenant_id; u := NEW.user_id; END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || t || ':' || u,0));
|
||||
IF EXISTS(SELECT 1 FROM staff_time_v2_accounts WHERE tenant_id=t AND user_id=u) THEN
|
||||
RAISE EXCEPTION 'Zeiterfassung 2.0 ist aktiv. Bitte die neue Erfassung verwenden.' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER staff_time_events_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_events FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_write_guard();
|
||||
CREATE TRIGGER staff_time_entries_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_entries FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_write_guard();
|
||||
--> statement-breakpoint
|
||||
CREATE FUNCTION staff_time_span_overlap_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || NEW.tenant_id || ':' || NEW.user_id,0));
|
||||
IF EXISTS(SELECT 1 FROM staff_time_spans s WHERE s.tenant_id=NEW.tenant_id AND s.user_id=NEW.user_id AND s.id<>NEW.id
|
||||
AND s.started_at<COALESCE(NEW.ended_at,'infinity'::timestamptz) AND COALESCE(s.ended_at,'infinity'::timestamptz)>NEW.started_at) THEN
|
||||
RAISE EXCEPTION 'Überlappende Zeitspannen' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER staff_time_span_no_overlap BEFORE INSERT OR UPDATE ON staff_time_spans FOR EACH ROW EXECUTE FUNCTION staff_time_span_overlap_guard();
|
||||
--> statement-breakpoint
|
||||
CREATE FUNCTION staff_time_legacy_project_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE parent staff_time_entries;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN SELECT * INTO parent FROM staff_time_entries WHERE id=OLD.time_entry_id;
|
||||
ELSE SELECT * INTO parent FROM staff_time_entries WHERE id=NEW.time_entry_id; END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || parent.tenant_id || ':' || parent.user_id,0));
|
||||
IF EXISTS(SELECT 1 FROM staff_time_v2_accounts WHERE tenant_id=parent.tenant_id AND user_id=parent.user_id) THEN
|
||||
RAISE EXCEPTION 'Projektzeiten unter 2.0 bearbeiten.' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER staff_time_project_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_entry_connects FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_project_guard();
|
||||
@@ -540,6 +540,13 @@
|
||||
"when": 1791072000000,
|
||||
"tag": "0079_notification_matrix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "7",
|
||||
"when": 1791158400000,
|
||||
"tag": "0080_staff_time_spans",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -105,3 +105,4 @@ export * from "./scanner_inbox"
|
||||
export * from "./collaboration";
|
||||
|
||||
export * from "./collaboration_federation"
|
||||
export * from './staff_time_spans'
|
||||
|
||||
48
backend/db/schema/staff_time_spans.ts
Normal file
48
backend/db/schema/staff_time_spans.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { pgTable, uuid, bigint, text, timestamp, date, integer, jsonb, primaryKey, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { tenants } from './tenants';
|
||||
import { authUsers } from './auth_users';
|
||||
import { projects } from './projects';
|
||||
const stamp = (name: string) => timestamp(name, { withTimezone: true });
|
||||
export const staffTimeV2Accounts = pgTable('staff_time_v2_accounts', {
|
||||
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull().references(() => tenants.id),
|
||||
user_id: uuid('user_id').notNull().references(() => authUsers.id),
|
||||
activated_at: stamp('activated_at').notNull().defaultNow(), activated_by: uuid('activated_by').references(() => authUsers.id),
|
||||
}, t => [primaryKey({ columns: [t.tenant_id, t.user_id] })]);
|
||||
export const staffTimeSpans = pgTable('staff_time_spans', {
|
||||
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), type: text('type').notNull(),
|
||||
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at'), date_from: date('date_from'), date_to: date('date_to'),
|
||||
timezone: text('timezone').notNull().default('Europe/Berlin'), description: text('description').notNull().default(''), source: text('source').notNull(),
|
||||
status: text('status').notNull().default('factual'), version: integer('version').notNull().default(1),
|
||||
created_at: stamp('created_at').notNull().defaultNow(), updated_at: stamp('updated_at').notNull().defaultNow(),
|
||||
}, t => [index('staff_time_spans_user_time').on(t.tenant_id, t.user_id, t.started_at),
|
||||
uniqueIndex('staff_time_spans_one_open').on(t.tenant_id, t.user_id).where(sql `${t.ended_at} IS NULL`),
|
||||
check('staff_time_spans_check', sql `${t.ended_at} IS NULL OR ${t.ended_at} > ${t.started_at}`),
|
||||
check('staff_time_spans_type_check', sql `${t.type} IN ('work','vacation','sick','overtime_compensation','holiday','other')`),
|
||||
check('staff_time_spans_status_check', sql `${t.status} IN ('factual','submitted','approved','rejected')`),
|
||||
check('staff_time_spans_version_check', sql `${t.version} > 0`),
|
||||
check('staff_time_spans_check1', sql `(${t.date_from} IS NULL AND ${t.date_to} IS NULL) OR (${t.type} <> 'work' AND ${t.date_from} IS NOT NULL AND ${t.date_to} IS NOT NULL AND ${t.date_to} > ${t.date_from} AND ${t.ended_at} IS NOT NULL)`),
|
||||
check('staff_time_spans_check2', sql `${t.status} = 'factual' OR ${t.ended_at} IS NOT NULL`)]);
|
||||
export const staffTimeSpanBreaks = pgTable('staff_time_span_breaks', {
|
||||
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
|
||||
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at'),
|
||||
}, t => [index('staff_time_span_breaks_parent').on(t.span_id), uniqueIndex('staff_time_span_breaks_one_open').on(t.span_id).where(sql `${t.ended_at} IS NULL`), check('staff_time_span_breaks_check', sql `${t.ended_at} IS NULL OR ${t.ended_at} > ${t.started_at}`)]);
|
||||
export const staffTimeSpanProjects = pgTable('staff_time_span_projects', {
|
||||
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
|
||||
project_id: bigint('project_id', { mode: 'number' }).notNull().references(() => projects.id),
|
||||
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at').notNull(), notes: text('notes').notNull().default(''),
|
||||
}, t => [index('staff_time_span_projects_parent').on(t.span_id), check('staff_time_span_projects_check', sql `${t.ended_at} > ${t.started_at}`)]);
|
||||
export const staffTimeSpanHistory = pgTable('staff_time_span_history', {
|
||||
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
|
||||
actor_id: uuid('actor_id').references(() => authUsers.id), action: text('action').notNull(), reason: text('reason').notNull().default(''),
|
||||
before_data: jsonb('before_data'), after_data: jsonb('after_data').notNull(), created_at: stamp('created_at').notNull().defaultNow(),
|
||||
}, t => [index('staff_time_span_history_parent').on(t.span_id, t.created_at)]);
|
||||
export const staffTimeV2Requests = pgTable('staff_time_v2_requests', {
|
||||
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull(), user_id: uuid('user_id').notNull(), request_id: text('request_id').notNull(),
|
||||
fingerprint: text('fingerprint').notNull(), response: jsonb('response').notNull(), created_at: stamp('created_at').notNull().defaultNow(),
|
||||
}, t => [primaryKey({ columns: [t.tenant_id, t.user_id, t.request_id] })]);
|
||||
export const staffTimeSpanOrigins = pgTable('staff_time_span_origins', {
|
||||
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull(), source_kind: text('source_kind').notNull(), source_id: uuid('source_id').notNull(),
|
||||
span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
|
||||
}, t => [primaryKey({ columns: [t.tenant_id, t.source_kind, t.source_id] })]);
|
||||
@@ -26,6 +26,7 @@ import helpdeskRoutes from "./routes/helpdesk";
|
||||
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
|
||||
import notificationsRoutes from "./routes/notifications";
|
||||
import staffTimeRoutes from "./routes/staff/time";
|
||||
import timeV2Routes from "./routes/staff/time-v2";
|
||||
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
||||
import userRoutes from "./routes/auth/user";
|
||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||
@@ -168,6 +169,7 @@ async function main() {
|
||||
await subApp.register(helpdeskRoutes);
|
||||
await subApp.register(notificationsRoutes);
|
||||
await subApp.register(staffTimeRoutes);
|
||||
await subApp.register(timeV2Routes);
|
||||
await subApp.register(staffTimeConnectRoutes);
|
||||
await subApp.register(userRoutes);
|
||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TimeService } from "./time-v2/service";
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
@@ -200,6 +201,14 @@ export const publicLinkService = {
|
||||
},
|
||||
|
||||
async submitFormData(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
|
||||
return server.db.transaction(async tx => {
|
||||
const scoped = Object.create(server);
|
||||
scoped.db = tx;
|
||||
return publicLinkService.submitFormDataTransaction(scoped, token, payload, providedPin);
|
||||
});
|
||||
},
|
||||
|
||||
async submitFormDataTransaction(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
|
||||
// 1. Validierung (Token & PIN)
|
||||
const linkConfig = await server.db.query.publicLinks.findFirst({
|
||||
where: eq(schema.publicLinks.token, token)
|
||||
@@ -223,7 +232,7 @@ export const publicLinkService = {
|
||||
|
||||
// Profil laden, um die user_id zu bekommen
|
||||
const authProfile = await server.db.query.authProfiles.findFirst({
|
||||
where: eq(schema.authProfiles.id, rawProfileId)
|
||||
where: and(eq(schema.authProfiles.id, rawProfileId), eq(schema.authProfiles.tenant_id, tenantId))
|
||||
});
|
||||
|
||||
if (!authProfile) throw new Error("Profile_Not_Found");
|
||||
@@ -245,7 +254,7 @@ export const publicLinkService = {
|
||||
// SCHRITT A: Stammdaten laden
|
||||
// =================================================================
|
||||
const project = await server.db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, payload.project)
|
||||
where: and(eq(schema.projects.id, payload.project), eq(schema.projects.tenant, tenantId))
|
||||
});
|
||||
if (!project) throw new Error("Project not found");
|
||||
|
||||
@@ -351,6 +360,18 @@ export const publicLinkService = {
|
||||
generatedDocumentId: createdDoc.id
|
||||
};
|
||||
|
||||
const timeService = new TimeService(server.db);
|
||||
const actor = { tenant: tenantId, user: userId, trusted: true };
|
||||
await timeService.lock(server.db, actor, userId);
|
||||
if (await timeService.active(actor, userId)) {
|
||||
await timeService.mutate(actor, userId, `public-link:${createdDoc.id}`, {
|
||||
action: 'create', data: {
|
||||
type: 'work', started_at: startDate.toISOString(), ended_at: endDate.toISOString(),
|
||||
description: eventMetadata.description, breaks: [],
|
||||
projects: [{ project_id: project.id, started_at: startDate.toISOString(), ended_at: endDate.toISOString(), notes: eventMetadata.description }],
|
||||
},
|
||||
}, 'public_link');
|
||||
} else {
|
||||
// 1. START EVENT
|
||||
await server.db.insert(schema.stafftimeevents).values({
|
||||
tenant_id: tenantId,
|
||||
@@ -375,6 +396,7 @@ export const publicLinkService = {
|
||||
metadata: eventMetadata
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// SCHRITT F: History Items
|
||||
|
||||
108
backend/src/modules/time-v2/compatibility.ts
Normal file
108
backend/src/modules/time-v2/compatibility.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { Actor, authorize, digest, rows, TimeService } from './service';
|
||||
import { fail, Span } from './domain';
|
||||
import { timeActor, uuid } from '../../routes/staff/time-v2';
|
||||
/** Existing consumers still receive work segments with pauses excluded, backed by a stable span ID. */
|
||||
export function legacySpans(spans: Span[]) {
|
||||
return spans.flatMap(s => {
|
||||
const base = { id: s.id, version: s.version, sourceEventIds: [s.id], status: s.status, user_id: s.user_id, description: s.description, payload: { description: s.description }, model: 'spans-v2', attendance: s };
|
||||
if (s.type !== 'work')
|
||||
return [{ ...base, type: s.type, startedAt: s.started_at, endedAt: s.ended_at }];
|
||||
const result: any[] = [];
|
||||
let start = s.started_at;
|
||||
for (const p of s.breaks) {
|
||||
if (Date.parse(start) < Date.parse(p.started_at))
|
||||
result.push({ ...base, type: 'work', startedAt: start, endedAt: p.started_at });
|
||||
result.push({ ...base, type: 'pause', startedAt: p.started_at, endedAt: p.ended_at });
|
||||
if (!p.ended_at)
|
||||
return result;
|
||||
start = p.ended_at;
|
||||
}
|
||||
if (!s.ended_at || Date.parse(start) < Date.parse(s.ended_at))
|
||||
result.push({ ...base, type: 'work', startedAt: start, endedAt: s.ended_at });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
export async function legacyEvent(service: TimeService, a: Actor, user: string, body: any, requestId?: string, source = 'legacy') {
|
||||
authorize(a, user, 'write');
|
||||
const key = `legacy:${digest({ body, requestId, actor: a.user, source })}`;
|
||||
const [cached] = await rows(service.db, sql `SELECT response FROM staff_time_v2_requests WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND request_id=${key}`);
|
||||
if (cached)
|
||||
return cached.response;
|
||||
const at = body.eventtime, type = body.eventtype;
|
||||
const command: any = { at };
|
||||
const active = await service.current({ ...a, trusted: true }, user);
|
||||
if (type === 'work_start')
|
||||
Object.assign(command, { action: 'start', description: body.payload?.description });
|
||||
else if (['work_end', 'auto_stop', 'pause_start', 'pause_end'].includes(type)) {
|
||||
if (!active)
|
||||
fail('Keine laufende Anwesenheit.', 409);
|
||||
Object.assign(command, { action: type === 'pause_start' ? 'pause' : type === 'pause_end' ? 'resume' : 'stop', id: active.id, version: active.version });
|
||||
}
|
||||
else if (/^(vacation|sick|overtime_compensation|holiday|other)_start$/.test(type)) {
|
||||
Object.assign(command, { action: 'create', data: { type: type.slice(0, -6), started_at: at, ended_at: null, description: body.payload?.description || '' } });
|
||||
}
|
||||
else if (/^(vacation|sick|overtime_compensation|holiday|other)_end$/.test(type)) {
|
||||
if (!active || active.type !== type.slice(0, -4))
|
||||
fail('Kein passender laufender Eintrag.', 409);
|
||||
Object.assign(command, { action: 'update', id: active.id, version: active.version, reason: 'Abschluss über bestehende Erfassung', data: { ...active, ended_at: at } });
|
||||
}
|
||||
else
|
||||
fail('Unbekannter Ereignistyp.');
|
||||
// For clients without IDs, the exact event payload is the stable key. Resolve retries before constructing a changed version.
|
||||
return service.mutate(a, user, key, command, source);
|
||||
}
|
||||
export function installTimeCompatibility(server: FastifyInstance, internal = false) {
|
||||
const service = new TimeService(server.db);
|
||||
server.addHook('preHandler', async (req, reply) => {
|
||||
try {
|
||||
const path = req.url.split('?')[0], b = (req.body || {}) as any, q = req.query as any;
|
||||
const operation = path.split('/').pop()!;
|
||||
const a: Actor = internal ? { ...timeActor(req), trusted: true } : timeActor(req);
|
||||
// Internal keys remain bound to their authenticated tenant and user context.
|
||||
if (internal && ((b.tenant_id && Number(b.tenant_id) !== a.tenant) || (q.tenantId && Number(q.tenantId) !== a.tenant)))
|
||||
fail('Falscher Mandant.', 403);
|
||||
let user = uuid(b.user_id || b.employeeUserId || q.targetUserId || a.user);
|
||||
const ids = b.eventIds || b.originalEventIds;
|
||||
if (Array.isArray(ids) && ids.length) {
|
||||
const [owner] = await rows(server.db, sql `SELECT user_id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND id=${uuid(ids[0])}::uuid`);
|
||||
if (owner)
|
||||
user = owner.user_id;
|
||||
}
|
||||
authorize(a, user, ['approve', 'reject'].includes(operation) ? 'approve' : req.method === 'GET' ? 'read' : 'write');
|
||||
if (!await service.active(a, user))
|
||||
return;
|
||||
if (operation === 'spans') {
|
||||
const spans = await service.list(a, user, q.from, q.to);
|
||||
const current = await service.current(a, user);
|
||||
if (current && !spans.some(s => s.id === current.id)) spans.push(current);
|
||||
return reply.send(legacySpans(spans));
|
||||
}
|
||||
if (operation === 'evaluation') {
|
||||
const result = await service.evaluation(a, user, q.from, q.to);
|
||||
return reply.send({ ...result, spans: legacySpans(result.spans) });
|
||||
}
|
||||
if (operation === 'event')
|
||||
return reply.send(await legacyEvent(service, a, user, b, req.headers['idempotency-key'] as string, internal ? 'terminal' : 'legacy'));
|
||||
if (operation === 'edit')
|
||||
fail('Dieser Eintrag wird unter 2.0 → Zeiterfassung einschließlich seiner Pausen bearbeitet.', 409);
|
||||
if (['submit', 'approve', 'reject'].includes(operation)) {
|
||||
if (!Array.isArray(ids) || ids.length !== 1)
|
||||
fail('Bitte vollständige Anwesenheiten einzeln unter 2.0 bearbeiten.', 409);
|
||||
const before = await service.get(a, uuid(ids[0]));
|
||||
if (before.status === ({ submit: 'submitted', approve: 'approved', reject: 'rejected' } as any)[operation])
|
||||
return reply.send(before);
|
||||
const result = await service.mutate(a, user, req.headers['idempotency-key'] as string || randomUUID(), {
|
||||
action: operation, id: before.id, version: before.version, reason: b.reason,
|
||||
}, 'legacy');
|
||||
return reply.send(result);
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
req.log.error(error);
|
||||
return reply.code(error.statusCode || 400).send({ error: error.statusCode ? error.message : 'Zeitaktion konnte nicht verarbeitet werden.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
167
backend/src/modules/time-v2/domain.ts
Normal file
167
backend/src/modules/time-v2/domain.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
export const TYPES = ['work', 'vacation', 'sick', 'overtime_compensation', 'holiday', 'other'] as const;
|
||||
export type TimeType = typeof TYPES[number];
|
||||
export type Period = {
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
};
|
||||
export type Span = Period & {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
type: TimeType;
|
||||
description: string;
|
||||
status: string;
|
||||
version?: number;
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
timezone: string;
|
||||
breaks: Period[];
|
||||
projects: (Period & {
|
||||
project_id: number;
|
||||
notes: string;
|
||||
})[];
|
||||
};
|
||||
export class TimeError extends Error {
|
||||
constructor(public statusCode: number, message: string) { super(message); }
|
||||
}
|
||||
export const fail = (message: string, status = 400): never => { throw new TimeError(status, message); };
|
||||
export function instant(value: unknown): string {
|
||||
if (typeof value !== 'string' || !/T.*(?:Z|[+-]\d\d:\d\d)$/.test(value) || !Number.isFinite(Date.parse(value)))
|
||||
fail('Zeitpunkt mit Zeitzone erforderlich.');
|
||||
dateOnly((value as string).slice(0, 10));
|
||||
return new Date(value as string).toISOString();
|
||||
}
|
||||
export function dateOnly(value: unknown): string {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value)
|
||||
fail('Ungültiges Datum.');
|
||||
return value as string;
|
||||
}
|
||||
export function midnight(date: string, zone = 'Europe/Berlin') { return dayjs.tz(dateOnly(date), zone).toISOString(); }
|
||||
export function nextDay(date: string) { return dayjs.utc(dateOnly(date)).add(1, 'day').format('YYYY-MM-DD'); }
|
||||
export function range(from: string, to: string, zone = 'Europe/Berlin') {
|
||||
dateOnly(from);
|
||||
dateOnly(to);
|
||||
if (from > to || dayjs.utc(to).diff(dayjs.utc(from), 'day') > 3660)
|
||||
fail('Zeitraum muss zwischen 1 und 3661 Tagen liegen.');
|
||||
return { started_at: midnight(from, zone), ended_at: midnight(nextDay(to), zone) };
|
||||
}
|
||||
const end = (p: Period) => p.ended_at ? Date.parse(p.ended_at) : Infinity;
|
||||
export const overlaps = (a: Period, b: Period) => Date.parse(a.started_at) < end(b) && Date.parse(b.started_at) < end(a);
|
||||
export const minutes = (p: Period, window: Period) => Math.max(0, Math.min(end(p), end(window)) - Math.max(Date.parse(p.started_at), Date.parse(window.started_at))) / 60000;
|
||||
export function normalizeSpan(input: any): Span {
|
||||
if (!input || !TYPES.includes(input.type))
|
||||
fail('Ungültige Zeitart.');
|
||||
const zone = input.timezone || 'Europe/Berlin';
|
||||
try {
|
||||
new Intl.DateTimeFormat('de', { timeZone: zone });
|
||||
}
|
||||
catch {
|
||||
fail('Ungültige Zeitzone.');
|
||||
}
|
||||
const allDay = input.date_from != null || input.date_to != null;
|
||||
const span: Span = {
|
||||
type: input.type, description: String(input.description || '').slice(0, 10000), timezone: zone,
|
||||
started_at: allDay ? midnight(input.date_from, zone) : instant(input.started_at),
|
||||
ended_at: allDay ? midnight(input.date_to, zone) : input.ended_at == null ? null : instant(input.ended_at),
|
||||
date_from: allDay ? dateOnly(input.date_from) : null, date_to: allDay ? dateOnly(input.date_to) : null,
|
||||
status: 'factual', breaks: [], projects: [],
|
||||
};
|
||||
if (allDay && (span.type === 'work' || span.date_to! <= span.date_from!))
|
||||
fail('Ungültiger ganztägiger Zeitraum; Ende ist exklusiv.');
|
||||
if (span.ended_at && end(span) <= Date.parse(span.started_at))
|
||||
fail('Ende muss nach Beginn liegen.');
|
||||
if (!Array.isArray(input.breaks || []) || !Array.isArray(input.projects || []))
|
||||
fail('Ungültige Teilzeiträume.');
|
||||
span.breaks = (input.breaks || []).map((p: any) => ({ started_at: instant(p.started_at), ended_at: p.ended_at == null ? null : instant(p.ended_at) }));
|
||||
span.projects = (input.projects || []).map((p: any) => {
|
||||
if (!Number.isSafeInteger(Number(p.project_id)) || Number(p.project_id) <= 0)
|
||||
fail('Projekt erforderlich.');
|
||||
return { started_at: instant(p.started_at), ended_at: instant(p.ended_at), project_id: Number(p.project_id), notes: String(p.notes || '').slice(0, 10000) };
|
||||
});
|
||||
if (span.type !== 'work' && (span.breaks.length || span.projects.length))
|
||||
fail('Pausen und Projekte benötigen eine Anwesenheit.');
|
||||
for (const parts of [span.breaks, span.projects]) {
|
||||
if (parts.length > 200)
|
||||
fail('Zu viele Teilzeiträume.');
|
||||
parts.sort((a, b) => Date.parse(a.started_at) - Date.parse(b.started_at));
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i];
|
||||
if (end(p) <= Date.parse(p.started_at) || Date.parse(p.started_at) < Date.parse(span.started_at) || end(p) > end(span))
|
||||
fail('Teilzeitraum muss innerhalb der Anwesenheit liegen.');
|
||||
if (i && overlaps(parts[i - 1], p))
|
||||
fail('Teilzeiträume dürfen sich nicht überschneiden.');
|
||||
}
|
||||
}
|
||||
for (const project of span.projects)
|
||||
if (span.breaks.some(p => overlaps(p, project)))
|
||||
fail('Projektzeit darf keine Pause enthalten.');
|
||||
return span;
|
||||
}
|
||||
export function transition(span: Span, action: string, reason = '') {
|
||||
if (!span.ended_at)
|
||||
fail('Laufende Einträge zuerst beenden.', 409);
|
||||
if (action === 'submit' && ['factual', 'rejected'].includes(span.status))
|
||||
return 'submitted';
|
||||
if (action === 'approve' && span.status === 'submitted')
|
||||
return 'approved';
|
||||
if (action === 'reject' && span.status === 'submitted' && reason.trim())
|
||||
return 'rejected';
|
||||
fail('Ungültiger Statuswechsel oder fehlender Ablehnungsgrund.', 409);
|
||||
}
|
||||
export function evaluate(spans: Span[], profile: any, holidayDates: string[], from: string, to: string, zone = 'Europe/Berlin') {
|
||||
const window = range(from, to, zone);
|
||||
const sum = { timeSpanWorkingMinutes: 0, sumWorkingMinutesSubmitted: 0, sumWorkingMinutesApproved: 0,
|
||||
sumWorkingMinutesRecreationDays: 0, sumRecreationDays: 0, sumWorkingMinutesVacationDays: 0, sumVacationDays: 0,
|
||||
sumWorkingMinutesSickDays: 0, sumSickDays: 0, sumOvertimeCompensationMinutes: 0, sumOtherMinutes: 0,
|
||||
presenceMinutes: 0, breakMinutes: 0, saldoApproved: 0, saldoSubmitted: 0 };
|
||||
for (const s of spans.filter(s => s.type === 'work' && s.ended_at)) {
|
||||
const gross = minutes(s, window), breaks = s.breaks.reduce((n, p) => n + minutes(p, window), 0), net = gross - breaks;
|
||||
sum.presenceMinutes += gross;
|
||||
sum.breakMinutes += breaks;
|
||||
if (['submitted', 'approved'].includes(s.status))
|
||||
sum.sumWorkingMinutesSubmitted += net;
|
||||
if (s.status === 'approved')
|
||||
sum.sumWorkingMinutesApproved += net;
|
||||
}
|
||||
const holidays = new Set(holidayDates);
|
||||
for (let date = from; date <= to; date = nextDay(date)) {
|
||||
const day = range(date, date, zone), weekday = dayjs.utc(date).day();
|
||||
const target = Math.max(0, Number(profile.weekly_regular_working_hours?.[weekday] || 0)) * 60;
|
||||
sum.timeSpanWorkingMinutes += target;
|
||||
// Credits are capped per day; holidays take precedence over overlapping absence credits.
|
||||
let remaining = target;
|
||||
const approved = spans.filter(s => s.type !== 'work' && s.status === 'approved' && s.ended_at && overlaps(s, day));
|
||||
if ((profile.recreation_days_compensation && holidays.has(date)) || approved.some(s => s.type === 'holiday')) {
|
||||
sum.sumWorkingMinutesRecreationDays += target;
|
||||
sum.sumRecreationDays += target > 0 ? 1 : 0;
|
||||
remaining = 0;
|
||||
}
|
||||
for (const type of ['vacation', 'sick', 'overtime_compensation', 'other']) {
|
||||
for (const s of approved.filter(s => s.type === type)) {
|
||||
const credit = Math.min(remaining, s.date_from ? target : minutes(s, day));
|
||||
if (type === 'vacation') {
|
||||
sum.sumWorkingMinutesVacationDays += credit;
|
||||
sum.sumVacationDays += target ? credit / target : 0;
|
||||
}
|
||||
if (type === 'sick') {
|
||||
sum.sumWorkingMinutesSickDays += credit;
|
||||
sum.sumSickDays += target ? credit / target : 0;
|
||||
}
|
||||
// Freizeitausgleich consumes the balance through the regular daily target, without a credit.
|
||||
if (type === 'overtime_compensation')
|
||||
sum.sumOvertimeCompensationMinutes += credit;
|
||||
if (type === 'other')
|
||||
sum.sumOtherMinutes += credit;
|
||||
remaining -= credit;
|
||||
}
|
||||
}
|
||||
}
|
||||
const credit = sum.sumWorkingMinutesRecreationDays + sum.sumWorkingMinutesVacationDays + sum.sumWorkingMinutesSickDays;
|
||||
sum.saldoApproved = sum.sumWorkingMinutesApproved + credit - sum.timeSpanWorkingMinutes;
|
||||
sum.saldoSubmitted = sum.sumWorkingMinutesSubmitted + credit - sum.timeSpanWorkingMinutes;
|
||||
return sum;
|
||||
}
|
||||
146
backend/src/modules/time-v2/migration.ts
Normal file
146
backend/src/modules/time-v2/migration.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { Actor, authorize, digest, rows, TimeService } from './service';
|
||||
import { fail, normalizeSpan, overlaps, Span } from './domain';
|
||||
type Candidate = {
|
||||
span: Span;
|
||||
origins: {
|
||||
kind: string;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
/** Conservative replay: never invent missing ends or silently discard ambiguous points. */
|
||||
export function previewLegacy(events: any[], entries: any[], connects: any[]) {
|
||||
const issues: string[] = [], candidates: Candidate[] = [];
|
||||
const invalidated = new Set(events.map(e => e.invalidates_event_id).filter(Boolean));
|
||||
const valid = events.filter(e => !invalidated.has(e.id) && e.eventtype !== 'invalidated');
|
||||
const states = new Map<string, string>();
|
||||
for (const e of [...valid].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id))) {
|
||||
if (['submitted', 'approved', 'rejected'].includes(e.eventtype) && e.related_event_id)
|
||||
states.set(e.related_event_id, e.eventtype);
|
||||
}
|
||||
const points = valid.filter(e => !['submitted', 'approved', 'rejected'].includes(e.eventtype))
|
||||
.sort((a, b) => Date.parse(a.eventtime) - Date.parse(b.eventtime) || String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id));
|
||||
let current: any = null, origins: Candidate['origins'] = [];
|
||||
const close = () => {
|
||||
if (!current)
|
||||
return;
|
||||
const statusSet = new Set(origins.map(o => states.get(o.id) || 'factual'));
|
||||
if (statusSet.size > 1)
|
||||
issues.push(`Uneinheitliche Freigaben ab ${current.started_at}; vor Übernahme vereinheitlichen.`);
|
||||
current.status = statusSet.size === 1 ? [...statusSet][0] : 'factual';
|
||||
if (!current.ended_at)
|
||||
issues.push(`Offener Alt-Eintrag ab ${current.started_at}; zuerst abschließen.`);
|
||||
try {
|
||||
const normalized = normalizeSpan(current);
|
||||
normalized.status = current.status;
|
||||
candidates.push({ span: normalized, origins: [...origins] });
|
||||
}
|
||||
catch (e: any) {
|
||||
issues.push(`${current.started_at}: ${e.message}`);
|
||||
}
|
||||
current = null;
|
||||
origins = [];
|
||||
};
|
||||
for (const e of points) {
|
||||
const type = e.eventtype === 'START' ? 'work_start' : e.eventtype === 'STOP' || e.eventtype === 'auto_stop' ? 'work_end' : e.eventtype;
|
||||
const time = new Date(e.eventtime).toISOString();
|
||||
if (type === 'pause_start' || type === 'pause_end') {
|
||||
if (!current || current.type !== 'work') {
|
||||
issues.push(`Pause ohne Anwesenheit: ${e.id}`);
|
||||
continue;
|
||||
}
|
||||
const open = current.breaks.find((p: any) => !p.ended_at);
|
||||
if (type === 'pause_start' && !open)
|
||||
current.breaks.push({ started_at: time, ended_at: null });
|
||||
else if (type === 'pause_end' && open)
|
||||
open.ended_at = time;
|
||||
else
|
||||
issues.push(`Widersprüchliche Pause: ${e.id}`);
|
||||
origins.push({ kind: 'event', id: e.id });
|
||||
continue;
|
||||
}
|
||||
if (type.endsWith('_start')) {
|
||||
if (current) {
|
||||
issues.push(`Neuer Beginn vor vorherigem Ende: ${e.id}`);
|
||||
close();
|
||||
}
|
||||
current = { type: type.slice(0, -6), started_at: time, ended_at: null, description: e.metadata?.description || e.payload?.description || '', breaks: [], projects: [], timezone: 'Europe/Berlin' };
|
||||
origins = [{ kind: 'event', id: e.id }];
|
||||
if (e.metadata?.project)
|
||||
current.projects.push({ project_id: Number(e.metadata.project), started_at: time, ended_at: null, notes: current.description });
|
||||
}
|
||||
else if (type.endsWith('_end')) {
|
||||
if (!current || current.type !== type.slice(0, -4)) {
|
||||
issues.push(`Ende ohne passenden Beginn: ${e.id}`);
|
||||
continue;
|
||||
}
|
||||
current.ended_at = time;
|
||||
for (const p of current.breaks)
|
||||
if (!p.ended_at)
|
||||
p.ended_at = time;
|
||||
for (const p of current.projects)
|
||||
p.ended_at = time;
|
||||
origins.push({ kind: 'event', id: e.id });
|
||||
close();
|
||||
}
|
||||
else
|
||||
issues.push(`Unbekanntes Ereignis ${e.eventtype}: ${e.id}`);
|
||||
}
|
||||
close();
|
||||
for (const e of entries.filter(e => !e.archived)) {
|
||||
try {
|
||||
const span = normalizeSpan({ ...e, ended_at: e.stopped_at, timezone: 'Europe/Berlin', breaks: [], projects: connects.filter(c => c.time_entry_id === e.id).map(c => ({ ...c, ended_at: c.stopped_at })) });
|
||||
span.status = e.state === 'draft' ? 'factual' : e.state;
|
||||
if (!span.ended_at)
|
||||
issues.push(`Offener Alt-Eintrag ${e.id}; zuerst abschließen.`);
|
||||
candidates.push({ span, origins: [{ kind: 'entry', id: e.id }] });
|
||||
}
|
||||
catch (err: any) {
|
||||
issues.push(`Alt-Eintrag ${e.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => Date.parse(a.span.started_at) - Date.parse(b.span.started_at));
|
||||
for (let i = 1; i < candidates.length; i++)
|
||||
if (overlaps(candidates[i - 1].span, candidates[i].span))
|
||||
issues.push(`Überlappende Alt-Einträge ab ${candidates[i].span.started_at}.`);
|
||||
const totalMinutes = candidates.reduce((n, { span: s }) => n + (s.type === 'work' && s.ended_at ? (Date.parse(s.ended_at) - Date.parse(s.started_at) - s.breaks.reduce((v, p) => v + (p.ended_at ? Date.parse(p.ended_at) - Date.parse(p.started_at) : 0), 0)) / 60000 : 0), 0);
|
||||
return { candidates, issues, totalMinutes, count: candidates.length, fingerprint: digest({ events, entries, connects }) };
|
||||
}
|
||||
export class TimeMigration {
|
||||
constructor(public service: TimeService) { }
|
||||
async preview(a: Actor, user: string, db = this.service.db) {
|
||||
authorize(a, user, 'activate');
|
||||
const events = await rows(db, sql `SELECT * FROM staff_time_events WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid ORDER BY event_time,id`);
|
||||
// Raw SQL column names differ from the historical Drizzle property names.
|
||||
const mapped = events.map(e => ({ ...e, eventtype: e.event_type, eventtime: e.event_time }));
|
||||
const entries = await rows(db, sql `SELECT * FROM staff_time_entries WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid ORDER BY started_at,id`);
|
||||
const connects = await rows(db, sql `SELECT c.* FROM staff_time_entry_connects c JOIN staff_time_entries e ON e.id=c.time_entry_id WHERE e.tenant_id=${a.tenant} AND e.user_id=${user}::uuid ORDER BY c.started_at,c.id`);
|
||||
const result = previewLegacy(mapped, entries, connects);
|
||||
for (const { span } of result.candidates)
|
||||
for (const project of span.projects) {
|
||||
if (!(await rows(db, sql `SELECT id FROM projects WHERE id=${project.project_id} AND tenant=${a.tenant}`)).length)
|
||||
result.issues.push(`Projekt ${project.project_id} fehlt im Mandanten.`);
|
||||
}
|
||||
return { ...result, active: await this.service.active(a, user, db) };
|
||||
}
|
||||
async activate(a: Actor, user: string, fingerprint: string) {
|
||||
authorize(a, user, 'activate');
|
||||
return this.service.db.transaction(async (tx: any) => {
|
||||
await this.service.lock(tx, a, user);
|
||||
if (await this.service.active(a, user, tx))
|
||||
return { active: true, alreadyActivated: true };
|
||||
const preview = await this.preview(a, user, tx);
|
||||
if (preview.fingerprint !== fingerprint)
|
||||
fail('Altdaten wurden geändert. Vorschau erneut laden.', 409);
|
||||
if (preview.issues.length)
|
||||
fail('Altdaten enthalten Konflikte. Vor Aktivierung korrigieren.', 409);
|
||||
for (const c of preview.candidates) {
|
||||
const created = await this.service.save(tx, a, user, c.span, null, 'migration', 'Übernahme aus bisheriger Zeiterfassung', 'migration');
|
||||
for (const origin of c.origins)
|
||||
await tx.execute(sql `INSERT INTO staff_time_span_origins(tenant_id,source_kind,source_id,span_id) VALUES(${a.tenant},${origin.kind},${origin.id}::uuid,${created.id}::uuid)`);
|
||||
}
|
||||
await tx.execute(sql `INSERT INTO staff_time_v2_accounts(tenant_id,user_id,activated_by) VALUES(${a.tenant},${user}::uuid,${a.user}::uuid)`);
|
||||
return { active: true, count: preview.count, totalMinutes: preview.totalMinutes };
|
||||
});
|
||||
}
|
||||
}
|
||||
189
backend/src/modules/time-v2/service.ts
Normal file
189
backend/src/modules/time-v2/service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { sql, SQL } from 'drizzle-orm';
|
||||
import { fail, normalizeSpan, transition, range, evaluate, Span, instant, overlaps } from './domain';
|
||||
export type Actor = {
|
||||
tenant: number;
|
||||
user: string;
|
||||
admin?: boolean;
|
||||
permissions?: string[];
|
||||
trusted?: boolean;
|
||||
};
|
||||
export const can = (a: Actor, permission: string) => Boolean(a.admin || a.trusted || a.permissions?.includes(permission));
|
||||
export function authorize(a: Actor, user: string, operation = 'read') {
|
||||
if (!a.tenant || !a.user || !user)
|
||||
fail('Anmeldung und Mandant erforderlich.', 403);
|
||||
if (operation === 'approve' || operation === 'activate') {
|
||||
if (!can(a, `staff.time.${operation}`))
|
||||
fail('Keine Berechtigung für diese Aktion.', 403);
|
||||
}
|
||||
else if (user !== a.user && !can(a, `staff.time.${operation}_all`) && !(operation === 'read' && ['write_all', 'approve', 'activate'].some(p => can(a, `staff.time.${p}`))))
|
||||
fail('Keine Berechtigung für fremde Zeiten.', 403);
|
||||
}
|
||||
export async function rows(db: any, query: SQL): Promise<any[]> {
|
||||
const result = await db.execute(query);
|
||||
const normalize = (value: any, key = ''): any => {
|
||||
if (value instanceof Date)
|
||||
return value.toISOString();
|
||||
if (Array.isArray(value))
|
||||
return value.map(v => normalize(v));
|
||||
if (value && typeof value === 'object')
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, normalize(v, k)]));
|
||||
if (typeof value === 'string' && (key.endsWith('_at') || key === 'event_time') && /^\d{4}-\d{2}-\d{2}[ T]/.test(value))
|
||||
return new Date(value).toISOString();
|
||||
return value;
|
||||
};
|
||||
return normalize(result.rows);
|
||||
}
|
||||
export const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
export class TimeService {
|
||||
constructor(public db: any) { }
|
||||
async lock(tx: any, a: Actor, user: string) {
|
||||
await tx.execute(sql `SELECT pg_advisory_xact_lock(hashtextextended(${`staff-time:${a.tenant}:${user}`},0))`);
|
||||
const member = await rows(tx, sql `SELECT user_id FROM auth_profiles WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid LIMIT 1`);
|
||||
if (!member.length)
|
||||
fail('Mitarbeiter im Mandanten nicht gefunden.', 404);
|
||||
}
|
||||
async active(a: Actor, user: string, db = this.db) {
|
||||
return (await rows(db, sql `SELECT * FROM staff_time_v2_accounts WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid`))[0] || null;
|
||||
}
|
||||
async list(a: Actor, user: string, from?: string, to?: string, db = this.db): Promise<Span[]> {
|
||||
authorize(a, user);
|
||||
const w = from && to ? range(from, to) : null;
|
||||
const filter = w ? sql `AND s.started_at < ${w.ended_at}::timestamptz AND (s.ended_at IS NULL OR s.ended_at > ${w.started_at}::timestamptz)` : sql ``;
|
||||
return rows(db, sql `SELECT s.*,
|
||||
COALESCE((SELECT jsonb_agg(b ORDER BY b.started_at) FROM staff_time_span_breaks b WHERE b.span_id=s.id),'[]'::jsonb) AS breaks,
|
||||
COALESCE((SELECT jsonb_agg(p ORDER BY p.started_at) FROM staff_time_span_projects p WHERE p.span_id=s.id),'[]'::jsonb) AS projects
|
||||
FROM staff_time_spans s WHERE s.tenant_id=${a.tenant} AND s.user_id=${user}::uuid ${filter} ORDER BY s.started_at DESC,s.id`);
|
||||
}
|
||||
async current(a: Actor, user: string, db = this.db): Promise<Span | null> {
|
||||
authorize(a, user);
|
||||
const [open] = await rows(db, sql `SELECT id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND ended_at IS NULL LIMIT 1`);
|
||||
return open ? this.get(a, open.id, db) : null;
|
||||
}
|
||||
async get(a: Actor, id: string, db = this.db): Promise<Span> {
|
||||
const [s] = await rows(db, sql `SELECT * FROM staff_time_spans WHERE id=${id}::uuid AND tenant_id=${a.tenant}`);
|
||||
if (!s)
|
||||
fail('Zeiteintrag nicht gefunden.', 404);
|
||||
authorize(a, s.user_id);
|
||||
s.breaks = await rows(db, sql `SELECT * FROM staff_time_span_breaks WHERE span_id=${id}::uuid ORDER BY started_at`);
|
||||
s.projects = await rows(db, sql `SELECT * FROM staff_time_span_projects WHERE span_id=${id}::uuid ORDER BY started_at`);
|
||||
return s;
|
||||
}
|
||||
async history(a: Actor, id: string) {
|
||||
await this.get(a, id);
|
||||
return rows(this.db, sql `SELECT * FROM staff_time_span_history WHERE span_id=${id}::uuid ORDER BY created_at,id`);
|
||||
}
|
||||
async save(tx: any, a: Actor, user: string, data: any, before: Span | null, action: string, reason: string, source: string): Promise<Span> {
|
||||
const s = normalizeSpan(data);
|
||||
s.status = data.status || 'factual';
|
||||
const conflict = await rows(tx, sql `SELECT id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid
|
||||
AND (${before?.id || null}::uuid IS NULL OR id <> ${before?.id || null}::uuid)
|
||||
AND started_at < COALESCE(${s.ended_at}::timestamptz,'infinity'::timestamptz)
|
||||
AND COALESCE(ended_at,'infinity'::timestamptz) > ${s.started_at}::timestamptz LIMIT 1`);
|
||||
if (conflict.length)
|
||||
fail('Der Zeitraum überschneidet sich mit einem bestehenden Eintrag.', 409);
|
||||
for (const p of s.projects) {
|
||||
if (!(await rows(tx, sql `SELECT id FROM projects WHERE id=${p.project_id} AND tenant=${a.tenant}`)).length)
|
||||
fail('Projekt gehört nicht zum Mandanten.', 400);
|
||||
}
|
||||
let id = before?.id;
|
||||
if (id) {
|
||||
await tx.execute(sql `UPDATE staff_time_spans SET type=${s.type},started_at=${s.started_at}::timestamptz,ended_at=${s.ended_at}::timestamptz,
|
||||
date_from=${s.date_from}::date,date_to=${s.date_to}::date,timezone=${s.timezone},description=${s.description},status=${s.status},version=version+1,updated_at=now() WHERE id=${id}::uuid`);
|
||||
await tx.execute(sql `DELETE FROM staff_time_span_breaks WHERE span_id=${id}::uuid`);
|
||||
await tx.execute(sql `DELETE FROM staff_time_span_projects WHERE span_id=${id}::uuid`);
|
||||
}
|
||||
else {
|
||||
const [created] = await rows(tx, sql `INSERT INTO staff_time_spans(tenant_id,user_id,type,started_at,ended_at,date_from,date_to,timezone,description,source,status)
|
||||
VALUES(${a.tenant},${user}::uuid,${s.type},${s.started_at}::timestamptz,${s.ended_at}::timestamptz,${s.date_from}::date,${s.date_to}::date,${s.timezone},${s.description},${source},${s.status}) RETURNING id`);
|
||||
id = created.id;
|
||||
}
|
||||
for (const p of s.breaks)
|
||||
await tx.execute(sql `INSERT INTO staff_time_span_breaks(span_id,started_at,ended_at) VALUES(${id}::uuid,${p.started_at}::timestamptz,${p.ended_at}::timestamptz)`);
|
||||
for (const p of s.projects)
|
||||
await tx.execute(sql `INSERT INTO staff_time_span_projects(span_id,project_id,started_at,ended_at,notes) VALUES(${id}::uuid,${p.project_id},${p.started_at}::timestamptz,${p.ended_at}::timestamptz,${p.notes})`);
|
||||
const after = await this.get({ ...a, trusted: true }, id!, tx);
|
||||
await tx.execute(sql `INSERT INTO staff_time_span_history(span_id,actor_id,action,reason,before_data,after_data)
|
||||
VALUES(${id}::uuid,${a.user}::uuid,${action},${reason},${before ? JSON.stringify(before) : null}::jsonb,${JSON.stringify(after)}::jsonb)`);
|
||||
return after;
|
||||
}
|
||||
async mutate(a: Actor, user: string, requestId: string, command: any, source = 'web') {
|
||||
const approval = ['approve', 'reject'].includes(command.action);
|
||||
authorize(a, user, approval ? 'approve' : 'write');
|
||||
if (typeof requestId !== 'string' || requestId.length < 8 || requestId.length > 200)
|
||||
fail('Eindeutige Anfrage-ID erforderlich.');
|
||||
const fingerprint = digest({ command, actor: a.user, source });
|
||||
return this.db.transaction(async (tx: any) => {
|
||||
await this.lock(tx, a, user);
|
||||
if (!await this.active(a, user, tx))
|
||||
fail('Zeiterfassung 2.0 zuerst mit Datenvorschau aktivieren.', 409);
|
||||
const [previous] = await rows(tx, sql `SELECT * FROM staff_time_v2_requests WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND request_id=${requestId}`);
|
||||
if (previous) {
|
||||
if (previous.fingerprint !== fingerprint)
|
||||
fail('Anfrage-ID wurde bereits für andere Daten verwendet.', 409);
|
||||
return previous.response;
|
||||
}
|
||||
const action = command.action;
|
||||
if (['create', 'start'].includes(action) && command.id)
|
||||
fail('Neue Einträge dürfen keine bestehende ID ersetzen.');
|
||||
let before: Span | null = null, data: any;
|
||||
if (command.id) {
|
||||
before = await this.get({ ...a, trusted: true }, command.id, tx);
|
||||
if (before.user_id !== user)
|
||||
fail('Eintrag gehört zu anderem Mitarbeiter.', 403);
|
||||
if (command.version !== before.version)
|
||||
fail('Eintrag wurde zwischenzeitlich geändert. Bitte neu laden.', 409);
|
||||
}
|
||||
if (action === 'create')
|
||||
data = { ...command.data, status: 'factual' };
|
||||
else if (action === 'start') {
|
||||
data = { type: 'work', started_at: instant(command.at), ended_at: null, description: command.description || '' };
|
||||
}
|
||||
else if (['stop', 'pause', 'resume'].includes(action)) {
|
||||
if (!before || before.type !== 'work' || before.ended_at)
|
||||
fail('Keine passende laufende Anwesenheit.', 409);
|
||||
data = { ...before, breaks: before.breaks.map(p => ({ ...p })) };
|
||||
const at = instant(command.at), open = data.breaks.find((p: any) => !p.ended_at);
|
||||
if (action === 'pause') {
|
||||
if (open)
|
||||
fail('Pause läuft bereits.', 409);
|
||||
data.breaks.push({ started_at: at, ended_at: null });
|
||||
}
|
||||
else {
|
||||
if (action === 'resume' && !open)
|
||||
fail('Keine laufende Pause.', 409);
|
||||
if (open)
|
||||
open.ended_at = at;
|
||||
if (action === 'stop')
|
||||
data.ended_at = at;
|
||||
}
|
||||
}
|
||||
else if (action === 'update') {
|
||||
if (!before)
|
||||
fail('Eintrag erforderlich.');
|
||||
if (!String(command.reason || '').trim())
|
||||
fail('Korrekturgrund erforderlich.');
|
||||
data = { ...command.data, status: 'factual' };
|
||||
}
|
||||
else if (['submit', 'approve', 'reject'].includes(action)) {
|
||||
if (!before)
|
||||
fail('Eintrag erforderlich.');
|
||||
data = { ...before, status: transition(before, action, command.reason) };
|
||||
}
|
||||
else
|
||||
fail('Unbekannte Aktion.');
|
||||
const response = await this.save(tx, a, user, data, before, action, String(command.reason || ''), source);
|
||||
await tx.execute(sql `INSERT INTO staff_time_v2_requests(tenant_id,user_id,request_id,fingerprint,response)
|
||||
VALUES(${a.tenant},${user}::uuid,${requestId},${fingerprint},${JSON.stringify(response)}::jsonb)`);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
async evaluation(a: Actor, user: string, from: string, to: string) {
|
||||
const spans = await this.list(a, user, from, to);
|
||||
const [profile] = await rows(this.db, sql `SELECT * FROM auth_profiles WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid LIMIT 1`);
|
||||
if (!profile)
|
||||
fail('Mitarbeiterprofil fehlt.', 404);
|
||||
const holidays = await rows(this.db, sql `SELECT DISTINCT date FROM holidays WHERE state_code IN ('DE',${profile.state_code || 'DE'}) AND date BETWEEN ${from}::date AND ${to}::date`);
|
||||
return { userId: user, spans, summary: { ...evaluate(spans, profile, holidays.map(h => h.date), from, to), from, to, user_id: user, tenant_id: a.tenant } };
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TimeService } from "../time-v2/service";
|
||||
import { legacySpans } from "../time-v2/compatibility";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {and, eq, gte, lte, asc, inArray} from "drizzle-orm";
|
||||
import {
|
||||
@@ -13,6 +15,14 @@ export async function generateTimesEvaluation(
|
||||
startDateInput: string,
|
||||
endDateInput: string
|
||||
) {
|
||||
const service = new TimeService(server.db);
|
||||
const actor = { tenant: tenant_id, user: user_id };
|
||||
if (await service.active(actor, user_id)) {
|
||||
const result = await service.evaluation(actor, user_id, startDateInput, endDateInput);
|
||||
return { ...result.summary, sumWorkingMinutesEingereicht: result.summary.sumWorkingMinutesSubmitted,
|
||||
saldo: result.summary.saldoApproved, saldoInOfficial: result.summary.saldoSubmitted,
|
||||
times: legacySpans(result.spans).map(s => ({ ...s, started_at: s.startedAt, stopped_at: s.endedAt, state: s.status })) };
|
||||
}
|
||||
const startDate = server.dayjs(startDateInput);
|
||||
const endDate = server.dayjs(endDateInput);
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { TimeService, digest, rows } from "../../modules/time-v2/service";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
|
||||
@@ -8,7 +11,8 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||
async (req, reply) => {
|
||||
try {
|
||||
// 1. Timestamp aus dem Body holen (optional)
|
||||
const { rfid_id, timestamp } = req.body as {
|
||||
const { rfid_id, timestamp, request_id } = req.body as {
|
||||
request_id?: string,
|
||||
rfid_id: string,
|
||||
timestamp?: number // Kann undefined sein (Live) oder Zahl (Offline)
|
||||
};
|
||||
@@ -51,11 +55,28 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||
return reply.code(400).send(`Profile for Token ${rfid_id} not found`);
|
||||
}
|
||||
|
||||
const timeService = new TimeService(server.db);
|
||||
const actor = { tenant: device.tenant, user: profile.user_id, trusted: true };
|
||||
if (profile.user_id && await timeService.active(actor, profile.user_id)) {
|
||||
return await server.db.transaction(async tx => {
|
||||
await timeService.lock(tx, actor, profile.user_id);
|
||||
const scoped = new TimeService(tx);
|
||||
const at = timestamp && timestamp > 0 ? new Date(timestamp * 1000).toISOString() : new Date().toISOString();
|
||||
const key = `rfid:${digest({ terminal_id, rfid_id, event: request_id || timestamp || randomUUID() })}`;
|
||||
const [cached] = await rows(tx, sql`SELECT response FROM staff_time_v2_requests WHERE tenant_id=${device.tenant} AND user_id=${profile.user_id}::uuid AND request_id=${key}`);
|
||||
if (cached) return cached.response;
|
||||
const current = await scoped.current(actor, profile.user_id);
|
||||
return scoped.mutate(actor, profile.user_id, key, current
|
||||
? { action: 'stop', id: current.id, version: current.version, at }
|
||||
: { action: 'start', at }, 'terminal');
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Letztes Event suchen (für Status-Toggle Work Start/End)
|
||||
const lastEvent = await server.db
|
||||
.select()
|
||||
.from(stafftimeevents)
|
||||
.where(eq(stafftimeevents.user_id, profile.user_id))
|
||||
.where(and(eq(stafftimeevents.user_id, profile.user_id), eq(stafftimeevents.tenant_id, device.tenant)))
|
||||
.orderBy(desc(stafftimeevents.eventtime))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installTimeCompatibility } from "../../modules/time-v2/compatibility";
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
stafftimeentries,
|
||||
@@ -18,6 +19,7 @@ import {z} from "zod";
|
||||
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||
|
||||
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
||||
installTimeCompatibility(server, true);
|
||||
|
||||
|
||||
server.post("/staff/time/event", async (req, reply) => {
|
||||
|
||||
62
backend/src/routes/staff/time-v2.ts
Normal file
62
backend/src/routes/staff/time-v2.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { Actor, can, rows, TimeService } from '../../modules/time-v2/service';
|
||||
import { TimeMigration } from '../../modules/time-v2/migration';
|
||||
import { fail, range } from '../../modules/time-v2/domain';
|
||||
export function timeActor(req: FastifyRequest): Actor {
|
||||
return { tenant: req.user.tenant_id!, user: req.user.user_id, admin: req.user.is_admin, permissions: req.permissions };
|
||||
}
|
||||
export function uuid(value: unknown): string {
|
||||
if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
||||
fail('Ungültige ID.');
|
||||
return value as string;
|
||||
}
|
||||
export default async function timeV2Routes(server: FastifyInstance) {
|
||||
const service = new TimeService(server.db), migration = new TimeMigration(service);
|
||||
server.setErrorHandler((error: any, req, reply) => {
|
||||
if (error.statusCode && error.statusCode < 500)
|
||||
return reply.code(error.statusCode).send({ error: error.message });
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ error: 'Zeiterfassung konnte nicht verarbeitet werden.' });
|
||||
});
|
||||
server.get('/v2/time/context', async (req) => {
|
||||
const a = timeActor(req);
|
||||
const all = can(a, 'staff.time.read_all') || can(a, 'staff.time.write_all') || can(a, 'staff.time.approve') || can(a, 'staff.time.activate');
|
||||
const profiles = await rows(server.db, sql `SELECT p.user_id,p.full_name,a.activated_at FROM auth_profiles p
|
||||
LEFT JOIN staff_time_v2_accounts a ON a.tenant_id=p.tenant_id AND a.user_id=p.user_id
|
||||
WHERE p.tenant_id=${a.tenant} AND p.user_id IS NOT NULL ${all ? sql `` : sql `AND p.user_id=${a.user}::uuid`} ORDER BY p.full_name`);
|
||||
const projects = await rows(server.db, sql `SELECT id,name FROM projects WHERE tenant=${a.tenant} AND NOT archived ORDER BY name`);
|
||||
return { profiles, projects, permissions: { readAll: all, writeAll: can(a, 'staff.time.write_all'), approve: can(a, 'staff.time.approve'), activate: can(a, 'staff.time.activate') } };
|
||||
});
|
||||
server.get('/v2/time/spans', async (req) => {
|
||||
const q = req.query as any, a = timeActor(req), user = uuid(q.user_id || a.user);
|
||||
if (!q.from || !q.to)
|
||||
fail('Zeitraum erforderlich.');
|
||||
range(q.from, q.to);
|
||||
const items = await service.list(a, user, q.from, q.to);
|
||||
const active = await service.current(a, user);
|
||||
return { items, active, account: await service.active(a, user) };
|
||||
});
|
||||
server.get('/v2/time/evaluation', async (req) => {
|
||||
const q = req.query as any, a = timeActor(req);
|
||||
range(q.from, q.to);
|
||||
return service.evaluation(a, uuid(q.user_id || a.user), q.from, q.to);
|
||||
});
|
||||
server.get('/v2/time/spans/:id/history', req => service.history(timeActor(req), uuid((req.params as any).id)));
|
||||
server.post('/v2/time/commands', async (req) => {
|
||||
const b = req.body as any, a = timeActor(req);
|
||||
if (!b || typeof b.command !== 'object' || !b.command)
|
||||
fail('Aktion erforderlich.');
|
||||
if (b.command.id)
|
||||
uuid(b.command.id);
|
||||
return service.mutate(a, uuid(b.user_id || a.user), b.request_id, b.command);
|
||||
});
|
||||
server.get('/v2/time/migration', req => {
|
||||
const a = timeActor(req);
|
||||
return migration.preview(a, uuid((req.query as any).user_id || a.user));
|
||||
});
|
||||
server.post('/v2/time/activate', req => {
|
||||
const b = req.body as any, a = timeActor(req);
|
||||
return migration.activate(a, uuid(b?.user_id || a.user), b?.fingerprint);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installTimeCompatibility } from "../../modules/time-v2/compatibility";
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
stafftimeentries,
|
||||
@@ -18,6 +19,7 @@ import {z} from "zod";
|
||||
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||
|
||||
export default async function staffTimeRoutes(server: FastifyInstance) {
|
||||
installTimeCompatibility(server);
|
||||
|
||||
|
||||
server.post("/staff/time/event", async (req, reply) => {
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { authorize, rows, TimeService } from "../../modules/time-v2/service";
|
||||
import { timeActor, uuid } from "./time-v2";
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { StaffTimeEntryConnect } from '../../types/staff'
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { stafftimenetryconnects } from "../../../db/schema";
|
||||
|
||||
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
||||
server.addHook('preHandler', async (req, reply) => {
|
||||
try {
|
||||
const a = timeActor(req), params = req.params as any;
|
||||
const id = uuid(params.id || params.connectId);
|
||||
const [owner] = await rows(server.db, params.id
|
||||
? sql`SELECT e.user_id,e.id FROM staff_time_entries e WHERE e.id=${id}::uuid AND e.tenant_id=${a.tenant}`
|
||||
: sql`SELECT e.user_id,e.id FROM staff_time_entry_connects c JOIN staff_time_entries e ON e.id=c.time_entry_id WHERE c.id=${id}::uuid AND e.tenant_id=${a.tenant}`);
|
||||
if (!owner) return reply.code(404).send({error:'Zeiteintrag nicht gefunden.'});
|
||||
authorize(a,owner.user_id,req.method === 'GET' ? 'read' : 'write');
|
||||
const service = new TimeService(server.db);
|
||||
if (await service.active(a,owner.user_id)) {
|
||||
if (req.method !== 'GET') return reply.code(409).send({error:'Projektzeiten unter 2.0 → Zeiterfassung bearbeiten.'});
|
||||
const [origin] = await rows(server.db,sql`SELECT span_id FROM staff_time_span_origins WHERE tenant_id=${a.tenant} AND source_kind='entry' AND source_id=${owner.id}::uuid`);
|
||||
const span = origin ? await service.get(a,origin.span_id) : null;
|
||||
return reply.send((span?.projects || []).map(p => ({...p,stopped_at:p.ended_at})));
|
||||
}
|
||||
} catch (e:any) { return reply.code(e.statusCode || 400).send({error:e.message}); }
|
||||
});
|
||||
|
||||
|
||||
// ▶ Connect anlegen
|
||||
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
|
||||
@@ -48,7 +70,8 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
||||
'/staff/time/connects/:connectId',
|
||||
async (req, reply) => {
|
||||
const { connectId } = req.params
|
||||
const patchData = { ...req.body } as any
|
||||
const patchData = Object.fromEntries(Object.entries(req.body).filter(([key]) =>
|
||||
['started_at', 'stopped_at', 'project_id', 'notes'].includes(key))) as any
|
||||
if (patchData.started_at) patchData.started_at = new Date(patchData.started_at)
|
||||
if (patchData.stopped_at) patchData.stopped_at = new Date(patchData.stopped_at)
|
||||
if (patchData.project_id !== undefined) {
|
||||
|
||||
@@ -302,6 +302,20 @@ export const buildTenantFullExport = async (
|
||||
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
|
||||
}
|
||||
|
||||
// Span children have no tenant column; include them through their scoped parent IDs.
|
||||
const timeSpanIds = collectIds(tables.staff_time_spans || [], "id")
|
||||
if (timeSpanIds.length) {
|
||||
for (const table of ["staff_time_span_breaks", "staff_time_span_projects", "staff_time_span_history"]) {
|
||||
if (columnsByTable.has(table)) {
|
||||
addRows(tables, table, await loadRows(client, table, "span_id = any($1::uuid[])", [timeSpanIds]))
|
||||
}
|
||||
}
|
||||
}
|
||||
const legacyTimeIds = collectIds(tables.staff_time_entries || [], "id")
|
||||
if (legacyTimeIds.length && columnsByTable.has("staff_time_entry_connects")) {
|
||||
addRows(tables, "staff_time_entry_connects", await loadRows(client, "staff_time_entry_connects", "time_entry_id = any($1::uuid[])", [legacyTimeIds]))
|
||||
}
|
||||
|
||||
const communicationRoomIds = collectIds(tables.communication_rooms || [], "id")
|
||||
if (communicationRoomIds.length) {
|
||||
addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds]))
|
||||
|
||||
207
backend/tests/timeV2.test.ts
Normal file
207
backend/tests/timeV2.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import Fastify from 'fastify';
|
||||
import { normalizeSpan, evaluate, range, transition, overlaps } from '../src/modules/time-v2/domain';
|
||||
import { TimeService, authorize } from '../src/modules/time-v2/service';
|
||||
import { TimeMigration, previewLegacy } from '../src/modules/time-v2/migration';
|
||||
import { legacySpans, installTimeCompatibility, legacyEvent } from '../src/modules/time-v2/compatibility';
|
||||
import timeV2Routes from '../src/routes/staff/time-v2';
|
||||
import { timeSpanInput } from '../../frontend/utils/timeSpanInput';
|
||||
import { staffTimeRows, workingMinutes } from '../../frontend/utils/staffTimeSpans';
|
||||
const at = (day: string, time: string) => `${day}T${time}:00Z`;
|
||||
const work = (start = '2026-10-01T06:00:00Z', end: string | null = '2026-10-01T14:30:00Z') => normalizeSpan({ type: 'work', started_at: start, ended_at: end,
|
||||
breaks: end ? [{ started_at: '2026-10-01T10:00:00Z', ended_at: '2026-10-01T10:30:00Z' }] : [] });
|
||||
const profile = { weekly_regular_working_hours: { 1: 8, 2: 8, 3: 8, 4: 8, 5: 8 }, recreation_days_compensation: true };
|
||||
test('attendance subtracts breaks and clips both at month boundaries', () => {
|
||||
const s = work();
|
||||
s.status = 'approved';
|
||||
const summary = evaluate([s], profile, [], '2026-10-01', '2026-10-01');
|
||||
assert.equal(summary.presenceMinutes, 510);
|
||||
assert.equal(summary.breakMinutes, 30);
|
||||
assert.equal(summary.sumWorkingMinutesApproved, 480);
|
||||
assert.equal(summary.saldoApproved, 0);
|
||||
const night = normalizeSpan({ type: 'work', started_at: '2026-09-30T21:00:00Z', ended_at: '2026-10-01T02:00:00Z', breaks: [{ started_at: '2026-09-30T21:45:00Z', ended_at: '2026-09-30T22:15:00Z' }] });
|
||||
night.status = 'approved';
|
||||
assert.equal(evaluate([night], profile, [], '2026-10-01', '2026-10-01').sumWorkingMinutesApproved, 225);
|
||||
});
|
||||
test('DST uses elapsed instants; all-day absence uses working days and avoids holiday double credit', () => {
|
||||
const dst = normalizeSpan({ type: 'work', started_at: '2026-10-25T00:00:00Z', ended_at: '2026-10-25T03:00:00Z' });
|
||||
dst.status = 'approved';
|
||||
assert.equal(evaluate([dst], profile, [], '2026-10-25', '2026-10-25').sumWorkingMinutesApproved, 180);
|
||||
const vacation = normalizeSpan({ type: 'vacation', date_from: '2026-10-01', date_to: '2026-10-06' });
|
||||
vacation.status = 'approved';
|
||||
const r = evaluate([vacation], profile, ['2026-10-02', '2026-10-02'], '2026-10-01', '2026-10-05');
|
||||
assert.equal(r.sumVacationDays, 2);
|
||||
assert.equal(r.sumWorkingMinutesVacationDays, 960);
|
||||
assert.equal(r.sumWorkingMinutesRecreationDays, 480);
|
||||
assert.equal(r.saldoApproved, 0);
|
||||
const comp = normalizeSpan({ type: 'overtime_compensation', date_from: '2026-10-01', date_to: '2026-10-02' });
|
||||
comp.status = 'approved';
|
||||
assert.equal(evaluate([comp], profile, [], '2026-10-01', '2026-10-01').saldoApproved, -480);
|
||||
assert.throws(() => range('2026-02-30', '2026-03-01'));
|
||||
});
|
||||
test('reject invalid intervals, pause collisions and projects inside breaks; allow adjacent attendances', () => {
|
||||
assert.throws(() => normalizeSpan({ type: 'work', started_at: at('2026-10-01', '08:00'), ended_at: at('2026-10-01', '07:00') }));
|
||||
const s = work();
|
||||
assert.throws(() => normalizeSpan({ ...s, breaks: [...s.breaks, { started_at: '2026-10-01T10:15:00Z', ended_at: '2026-10-01T11:00:00Z' }] }));
|
||||
assert.throws(() => normalizeSpan({ ...s, projects: [{ project_id: 1, started_at: '2026-10-01T09:00:00Z', ended_at: '2026-10-01T11:00:00Z' }] }));
|
||||
assert.equal(overlaps(s, { started_at: s.ended_at!, ended_at: null }), false);
|
||||
assert.throws(() => transition(work(undefined, null), 'submit'));
|
||||
assert.throws(() => transition(s, 'approve'));
|
||||
s.status = 'submitted';
|
||||
assert.throws(() => transition(s, 'reject', ''));
|
||||
assert.equal(transition(s, 'reject', 'Korrektur'), 'rejected');
|
||||
});
|
||||
test('migration preserves the overtime type and flags missing boundaries, mixed approvals and duplicate models', () => {
|
||||
const user = randomUUID();
|
||||
const events = [{ id: 'a', eventtype: 'overtime_compensation_start', eventtime: at('2026-10-01', '06:00') }, { id: 'b', eventtype: 'overtime_compensation_end', eventtime: at('2026-10-01', '14:00') }];
|
||||
let p = previewLegacy(events, [], []);
|
||||
assert.equal(p.issues.length, 0);
|
||||
assert.equal(p.candidates[0].span.type, 'overtime_compensation');
|
||||
p = previewLegacy(events.slice(0, 1), [], []);
|
||||
assert.match(p.issues.join(' '), /Offener/);
|
||||
p = previewLegacy([...events, { id: 'c', eventtype: 'approved', related_event_id: 'a', created_at: at('2026-10-02', '08:00') }], [], []);
|
||||
assert.match(p.issues.join(' '), /Uneinheitliche/);
|
||||
p = previewLegacy(events, [{ id: 'd', user_id: user, type: 'work', started_at: events[0].eventtime, stopped_at: events[1].eventtime, state: 'draft' }], []);
|
||||
assert.match(p.issues.join(' '), /Überlappende/);
|
||||
});
|
||||
test('own access and role separation', () => {
|
||||
const a = { tenant: 1, user: 'a' };
|
||||
assert.doesNotThrow(() => authorize(a, 'a', 'write'));
|
||||
assert.throws(() => authorize(a, 'b', 'read'));
|
||||
assert.throws(() => authorize(a, 'a', 'approve'));
|
||||
assert.doesNotThrow(() => authorize({ ...a, permissions: ['staff.time.approve'] }, 'b', 'read'));
|
||||
assert.throws(() => authorize({ ...a, permissions: ['staff.time.read_all'] }, 'b', 'write'));
|
||||
});
|
||||
const databaseUrl = process.env.TIME_V2_TEST_DATABASE_URL;
|
||||
async function fixture() {
|
||||
if (!databaseUrl || new URL(databaseUrl).pathname !== '/time_v2_test')
|
||||
throw new Error('Use an explicitly designated /time_v2_test database.');
|
||||
const schema = `time_v2_${randomUUID().replaceAll('-', '')}`;
|
||||
const admin = new Pool({ connectionString: databaseUrl, max: 1 });
|
||||
await admin.query(`CREATE SCHEMA ${schema}`);
|
||||
const pool = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}`, max: 8 });
|
||||
const db = drizzle(pool);
|
||||
try {
|
||||
await pool.query(`CREATE TABLE tenants(id bigint PRIMARY KEY);
|
||||
CREATE TABLE auth_users(id uuid PRIMARY KEY);
|
||||
CREATE TABLE auth_profiles(user_id uuid,tenant_id bigint,full_name text,weekly_regular_working_hours jsonb,state_code text,recreation_days_compensation boolean);
|
||||
CREATE TABLE projects(id bigint PRIMARY KEY,tenant bigint,name text,archived boolean DEFAULT false);
|
||||
CREATE TABLE holidays(date date,state_code text);
|
||||
CREATE TABLE staff_time_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id bigint,user_id uuid,event_type text,event_time timestamptz,created_at timestamptz DEFAULT now(),invalidates_event_id uuid,related_event_id uuid,metadata jsonb);
|
||||
CREATE TABLE staff_time_entries(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id bigint,user_id uuid,type text,started_at timestamptz,stopped_at timestamptz,state text,description text,archived boolean DEFAULT false);
|
||||
CREATE TABLE staff_time_entry_connects(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),time_entry_id uuid,project_id bigint,started_at timestamptz,stopped_at timestamptz,notes text);`);
|
||||
await pool.query(await readFile(new URL('../db/migrations/0080_staff_time_spans.sql', import.meta.url), 'utf8'));
|
||||
const user = randomUUID(), other = randomUUID(), outsider = randomUUID();
|
||||
await pool.query('INSERT INTO tenants VALUES (1),(2)');
|
||||
await pool.query('INSERT INTO auth_users VALUES ($1),($2),($3)', [user, other, outsider]);
|
||||
await pool.query(`INSERT INTO auth_profiles VALUES ($1,1,'Anna',$4,'DE-NI',true),($2,1,'Ben',$4,'DE-NI',true),($3,2,'Outside',$4,'DE-NI',true)`, [user, other, outsider, JSON.stringify(profile.weekly_regular_working_hours)]);
|
||||
await pool.query("INSERT INTO projects VALUES (1,1,'Project',false),(2,2,'Other',false)");
|
||||
const service = new TimeService(db), migration = new TimeMigration(service), actor = { tenant: 1, user, admin: true };
|
||||
return { pool, db, user, other, outsider, service, migration, actor, close: async () => { await pool.end(); await admin.query(`DROP SCHEMA ${schema} CASCADE`); await admin.end(); } };
|
||||
}
|
||||
catch (e) {
|
||||
await pool.end();
|
||||
await admin.query(`DROP SCHEMA ${schema} CASCADE`);
|
||||
await admin.end();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
test('PostgreSQL: atomic adoption, idempotence, concurrency, workflow and tenant isolation', { skip: !databaseUrl }, async () => {
|
||||
const f = await fixture();
|
||||
const { service: s, migration: m, actor: a, user, pool } = f;
|
||||
try {
|
||||
await pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES (1,$1,'work_start','2026-09-01T06:00:00Z'),(1,$1,'pause_start','2026-09-01T10:00:00Z'),(1,$1,'pause_end','2026-09-01T10:30:00Z'),(1,$1,'work_end','2026-09-01T14:30:00Z')`, [user]);
|
||||
const preview = await m.preview(a, user);
|
||||
assert.equal(preview.count, 1);
|
||||
assert.equal(preview.totalMinutes, 480);
|
||||
await assert.rejects(m.activate(a, user, 'stale'), /Vorschau/);
|
||||
await m.activate(a, user, preview.fingerprint);
|
||||
assert.equal((await s.list(a, user)).length, 1);
|
||||
assert.equal((await m.activate(a, user, preview.fingerprint)).alreadyActivated, true);
|
||||
await assert.rejects(pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES(1,$1,'work_start',now())`, [user]), /2.0/);
|
||||
const key = randomUUID(), cmd = { action: 'start', at: '2026-10-01T06:00:00Z' };
|
||||
const [one, two] = await Promise.all([s.mutate(a, user, key, cmd), s.mutate(a, user, key, cmd)]);
|
||||
assert.equal(one.id, two.id);
|
||||
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'create', id: one.id, version: one.version, data: work() }), /bestehende ID/);
|
||||
const concurrent = await Promise.allSettled([s.mutate(a, user, randomUUID(), cmd), s.mutate(a, user, randomUUID(), cmd)]);
|
||||
assert.equal(concurrent.filter(r => r.status === 'fulfilled').length, 0);
|
||||
await assert.rejects(s.mutate(a, user, key, { ...cmd, at: '2026-10-01T07:00:00Z' }), /Anfrage-ID/);
|
||||
await assert.rejects(s.mutate(a, user, randomUUID(), cmd), /überschneidet/);
|
||||
let item = await s.mutate(a, user, randomUUID(), { action: 'pause', id: one.id, version: one.version, at: '2026-10-01T10:00:00Z' });
|
||||
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'stop', id: one.id, version: one.version, at: '2026-10-01T10:30:00Z' }), /zwischenzeitlich/);
|
||||
item = await s.mutate(a, user, randomUUID(), { action: 'stop', id: item.id, version: item.version, at: '2026-10-01T10:30:00Z' });
|
||||
assert.equal(item.breaks[0].ended_at, item.ended_at);
|
||||
assert.equal((await s.evaluation(a, user, '2026-10-01', '2026-10-01')).summary.presenceMinutes, 270);
|
||||
await assert.rejects(s.mutate({ ...a, admin: false }, user, randomUUID(), { action: 'approve', id: item.id, version: item.version }), /Berechtigung/);
|
||||
item = await s.mutate(a, user, randomUUID(), { action: 'submit', id: item.id, version: item.version });
|
||||
item = await s.mutate(a, user, randomUUID(), { action: 'approve', id: item.id, version: item.version });
|
||||
assert.equal((await s.evaluation(a, user, '2026-10-01', '2026-10-01')).summary.sumWorkingMinutesApproved, 240);
|
||||
const changed = await s.mutate(a, user, randomUUID(), { action: 'update', id: item.id, version: item.version, reason: 'Notiz ergänzt', data: { ...item, description: 'Neu' } });
|
||||
assert.equal(changed.status, 'factual');
|
||||
assert.equal((await s.history(a, item.id)).length, 6);
|
||||
await assert.rejects(s.get({ ...a, tenant: 2, user: f.outsider }, item.id), /nicht gefunden/);
|
||||
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'update', id: changed.id, version: changed.version, reason: 'Projekt', data: { ...changed, projects: [{ project_id: 2, started_at: '2026-10-01T06:00:00Z', ended_at: '2026-10-01T07:00:00Z' }] } }), /Mandanten/);
|
||||
assert.equal((await s.get(a, item.id)).version, changed.version);
|
||||
const compat = legacySpans([changed]);
|
||||
assert.equal(compat.length, 2);
|
||||
assert.equal(compat[0].endedAt, '2026-10-01T10:00:00.000Z');
|
||||
}
|
||||
finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('HTTP routes: validation, permissions, compatibility and migration conflicts', { skip: !databaseUrl }, async () => {
|
||||
const f = await fixture(), app = Fastify();
|
||||
app.decorate('db', f.db as any);
|
||||
app.addHook('preHandler', async (req) => { req.user = { user_id: f.user, tenant_id: 1, email: 'test@example.test', is_admin: req.headers['x-admin'] === 'yes' }; req.permissions = []; });
|
||||
await app.register(timeV2Routes, { prefix: '/api' });
|
||||
await app.register(async (legacy) => { installTimeCompatibility(legacy); legacy.get('/staff/time/spans', async () => []); legacy.post('/staff/time/event', async () => ({ old: true })); }, { prefix: '/api' });
|
||||
try {
|
||||
assert.equal((await app.inject('/api/v2/time/spans?user_id=bad')).statusCode, 400);
|
||||
assert.equal((await app.inject(`/api/v2/time/spans?user_id=${f.other}&from=2026-10-01&to=2026-10-02`)).statusCode, 403);
|
||||
assert.equal((await app.inject('/api/v2/time/migration')).statusCode, 403);
|
||||
const preview = (await app.inject({ url: '/api/v2/time/migration', headers: { 'x-admin': 'yes' } })).json();
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/v2/time/activate', headers: { 'x-admin': 'yes' }, payload: { fingerprint: preview.fingerprint } })).statusCode, 200);
|
||||
const body = { eventtype: 'work_start', eventtime: '2026-10-01T06:00:00Z', user_id: f.user };
|
||||
const created = await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: body });
|
||||
assert.equal(created.statusCode, 200, created.body);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: body })).json().id, created.json().id);
|
||||
assert.equal((await app.inject('/api/staff/time/spans?from=2026-09-01&to=2026-09-02')).json().length, 1, 'Running attendance survives a different selected month');
|
||||
const stop = { ...body, eventtype: 'work_end', eventtime: '2026-10-01T08:00:00Z' };
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: stop })).statusCode, 200);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: stop })).statusCode, 200);
|
||||
const spans = (await app.inject('/api/staff/time/spans')).json();
|
||||
assert.equal(spans.length, 1);
|
||||
assert.equal(spans[0].model, 'spans-v2');
|
||||
await f.pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES(1,$1,'work_start',now())`, [f.other]);
|
||||
const p = await f.migration.preview(f.actor, f.other);
|
||||
assert.ok(p.issues.length);
|
||||
await assert.rejects(f.migration.activate(f.actor, f.other, p.fingerprint), /Konflikte/);
|
||||
assert.equal(await f.service.active(f.actor, f.other), null);
|
||||
}
|
||||
finally {
|
||||
await app.close();
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('editing repeated local times preserves the original instant and rejects ambiguous new inputs', () => {
|
||||
assert.equal(timeSpanInput('2026-10-25T02:15', 'Europe/Berlin', '2026-10-25T01:15:00.000Z'), '2026-10-25T01:15:00.000Z');
|
||||
assert.equal(timeSpanInput('2026-10-25T02:15', 'Europe/Berlin', '2026-10-25T00:15:00.000Z'), '2026-10-25T00:15:00.000Z');
|
||||
assert.throws(() => timeSpanInput('2026-10-25T02:15', 'Europe/Berlin'), /zweimal/);
|
||||
assert.throws(() => timeSpanInput('2026-03-29T02:15', 'Europe/Berlin'), /existiert nicht/);
|
||||
assert.equal(timeSpanInput('2026-10-25T01:15', 'UTC'), '2026-10-25T01:15:00.000Z');
|
||||
});
|
||||
|
||||
test('dashboard keeps a paused attendance active and freezes its net working duration', () => {
|
||||
const s = work(undefined, null); s.id = randomUUID(); s.user_id = randomUUID();
|
||||
s.breaks = [{started_at:'2026-10-01T10:00:00Z',ended_at:null}];
|
||||
const entries = staffTimeRows(legacySpans([s]),s.user_id,Date.parse('2026-10-01T11:00:00Z'));
|
||||
assert.equal(entries.length,1); assert.equal(entries[0].type,'work');
|
||||
assert.equal((entries[0] as any).paused,true);assert.equal(entries[0].stopped_at,null);
|
||||
assert.equal(workingMinutes(entries[0],Date.parse('2026-10-01T12:00:00Z')),240);
|
||||
});
|
||||
@@ -10,3 +10,5 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||
- [Konzept zum Austausch von MinIO](./minio-austausch-konzept.md)
|
||||
- [MinIO-Umstellung: ausführbarer Pilot](./minio-umstellung-pilot.md)
|
||||
- [Konzept für föderierte Chats und externe Projektteilnehmer](./chat-foederation-konzept.md)
|
||||
|
||||
- [Zeiterfassung 2.0 und Datenübernahme](./zeiterfassung-2.0.md)
|
||||
|
||||
83
docs/zeiterfassung-2.0.md
Normal file
83
docs/zeiterfassung-2.0.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Zeiterfassung 2.0
|
||||
|
||||
Unter **2.0 → Zeiterfassung** werden Anwesenheiten direkt als Zeitspannen gespeichert. Eine Anwesenheit enthält Beginn, Ende, Pausen und optionale Projektzuordnungen. Arbeitszeit ist die Anwesenheitsdauer abzüglich der Pausen. Die bisherige Erfassung bleibt für noch nicht umgestellte Mitarbeiter verfügbar.
|
||||
|
||||
## Erfassung und Freigaben
|
||||
|
||||
**Kommen** öffnet eine Anwesenheit. **Pause beginnen** und **Pause beenden** erfassen Pausen innerhalb dieser Anwesenheit. **Gehen** schließt die Anwesenheit und gegebenenfalls eine laufende Pause. Manuelle Einträge können mehrere Tage umfassen. Überschneidungen, Pausen außerhalb der Anwesenheit und Projektzeiten innerhalb einer Pause werden abgelehnt. Projektzuordnungen sind exklusive Teilzeiträume; mehrere Projekte können nicht dieselbe Arbeitszeit beanspruchen.
|
||||
|
||||
Abgeschlossene Einträge können eingereicht werden. Berechtigte Personen können sie genehmigen oder mit Begründung ablehnen. Korrekturen verlangen einen Grund und setzen den Status auf **Erfasst** zurück. Damit wird die korrigierte Fassung erneut eingereicht und genehmigt. Der Verlauf enthält die vorherige und die neue Fassung, den Akteur, die Aktion und den Grund. Pausen und Projekte werden gemeinsam mit der Anwesenheit freigegeben.
|
||||
|
||||
Urlaub, Krankheit, Freizeitausgleich, Feiertag und Sonstiges werden als eigene Spannen geführt. Ganztägige Einträge verwenden in der Oberfläche einen ersten und letzten Tag einschließlich. Die API speichert das Enddatum exklusiv. Anwesenheiten und Abwesenheiten dürfen sich nicht überschneiden; bei teilweiser Abwesenheit sind Uhrzeitspannen zu verwenden.
|
||||
|
||||
## Auswertung
|
||||
|
||||
Die Auswertung verwendet Europe/Berlin für Tagesgrenzen und begrenzt jede Spanne auf den gewählten Zeitraum. Nachtschichten zählen anteilig zu den jeweiligen Tagen oder Monaten. Zeitumstellungen werden anhand der tatsächlich vergangenen Zeit berücksichtigt. In manuellen Eingaben wird die angegebene IANA-Zeitzone verwendet; nicht existierende Ortszeiten werden im Formular abgelehnt. Unveränderte Zeitpunkte behalten ihren ursprünglichen Offset. Bei neu eingegebenen, doppelt vorkommenden Ortszeiten verlangt das Formular die Eingabe in UTC. Die API akzeptiert Zeitpunkte mit explizitem UTC-Offset.
|
||||
|
||||
Sollzeit stammt aus den regelmäßigen Wochenarbeitszeiten des Mitarbeiterprofils. Arbeitszeitsummen enthalten nur abgeschlossene Einträge; eingereicht umfasst auch genehmigte Zeiten. Urlaub und Krankheit werden nur genehmigt gutgeschrieben. Ganztägige Abwesenheiten zählen an Arbeitstagen gemäß Sollzeit, zeitweise Abwesenheiten höchstens bis zur Tagessollzeit. Feiertagsausgleich hat Vorrang vor Abwesenheitsgutschriften und zählt pro Datum einmal. Die Summe der Gutschriften überschreitet die Tagessollzeit nicht.
|
||||
|
||||
Freizeitausgleich und Sonstiges werden ausgewiesen, erzeugen aber keine zusätzliche Zeitgutschrift. Ein freier Arbeitstag zum Überstundenabbau reduziert deshalb den Saldo um seine Sollzeit. Aktuelle Anwesenheit und Pausen sind unabhängig vom ausgewählten Auswertungsmonat sichtbar.
|
||||
|
||||
## Berechtigungen
|
||||
|
||||
Eigene Zeiten können angemeldete Mitarbeiter mit Profil im aktiven Mandanten erfassen. Folgende Berechtigungen werden im Backend durchgesetzt:
|
||||
|
||||
| Berechtigung | Funktion |
|
||||
| --- | --- |
|
||||
| `staff.time.read_all` | Zeiten anderer Mitarbeiter lesen |
|
||||
| `staff.time.write_all` | Zeiten anderer Mitarbeiter lesen, erfassen, korrigieren und einreichen |
|
||||
| `staff.time.approve` | Zeiten lesen und eingereichte Zeiten genehmigen oder ablehnen |
|
||||
| `staff.time.activate` | Zeiten lesen, Übernahme prüfen und Mitarbeiter umstellen |
|
||||
|
||||
Administratoren können diese Aktionen ebenfalls ausführen. Die Rechte werden als Berechtigungen der bestehenden Authentifizierungsrollen vergeben. Ein Lese- oder Genehmigungsrecht erlaubt keine Korrektur fremder Zeiten. Die Menüanzeige folgt dem vorhandenen Mandantenmerkmal `staffTime`.
|
||||
|
||||
## Einführung und Datenübernahme
|
||||
|
||||
1. Backend und Frontend gemeinsam bereitstellen. Vor Start des neuen Backends die regulären Datenbankmigrationen ausführen (`npm run migrate` im Backend). Die additive Migration `0080_staff_time_spans` legt die neuen Tabellen und Schutzfunktionen an. Sie aktiviert niemanden automatisch.
|
||||
2. Als Administrator einen Mitarbeiter unter **2.0 → Zeiterfassung** auswählen und **Übernahme prüfen** anklicken.
|
||||
3. Anzahl, Arbeitsminuten und Einträge in der Vorschau prüfen. Offene Einträge, fehlende Enden, unbekannte Ereignisse, uneinheitliche Freigaben oder überlappende Daten aus den beiden alten Modellen verhindern die Aktivierung. Diese Fälle müssen vorab in den Altdaten geklärt werden. Es werden keine Endzeiten erfunden und keine doppelten Einträge stillschweigend verworfen.
|
||||
4. **Geprüfte Daten übernehmen und aktivieren** übernimmt die vollständige Historie dieses Mitarbeiters in einer Transaktion. Wenn sich die Altdaten seit der Vorschau geändert haben, muss die Vorschau neu geladen werden. Wiederholte Aktivierung erzeugt keine Duplikate.
|
||||
5. Nach Aktivierung kommen auch Lesezugriffe auf historische Zeiträume aus dem neuen Modell. Die alten Datensätze bleiben erhalten; Quellverweise verbinden sie mit den übernommenen Spannen. Alte Schreibzugriffe werden durch Datenbanktrigger gesperrt. Eine Rückkehr zum alten Schreibmodell ist kein unterstützter Schalter.
|
||||
|
||||
Die erste Aktivierung sollte mit einem Mitarbeiter erfolgen, dessen Zeiten und Salden bekannt sind. Zusätzlich zur Arbeitsminutensumme sollten die Freigaben, Abwesenheitstage und Projektzuordnungen der Vorschau fachlich geprüft werden. Fehler der bisherigen Ereignisrekonstruktion können zu abweichenden, nun korrekt berechneten Ergebnissen führen. Der Import erhält die ursprünglichen Uhrzeitgrenzen; er interpretiert alte Uhrzeitspannen nicht nachträglich als ganztägige Abwesenheit.
|
||||
|
||||
Vor einer Bereitstellung ist eine Datenbanksicherung nötig. Nach erfolgter Umstellung ist bei Problemen eine Vorwärtskorrektur vorzuziehen. Ein vollständiges Zurücksetzen auf die Sicherung würde zwischenzeitlich erfasste Zeiten verlieren und ist eine gesonderte Betriebsentscheidung.
|
||||
|
||||
## Bestehende Anbindungen
|
||||
|
||||
Die bisherigen Schnittstellen für Start, Ende, Einreichen, Genehmigen, Ablehnen und Auswertung erkennen aktivierte Mitarbeiter. Sie arbeiten dann auf den neuen Spannen. Die Webansicht unter Mitarbeiter → Zeiten wechselt für aktivierte Mitarbeiter zur neuen Oberfläche. Dashboard, Anwesenheitsanzeige und Plantafel lesen über diese kompatiblen Schnittstellen. Die Anwesenheitsanzeige berücksichtigt laufende Pausen.
|
||||
|
||||
Die Mobile-App fasst die kompatiblen Arbeitssegmente zu einer Anwesenheit zusammen und bietet Pausenaktionen für umgestellte Mitarbeiter. RFID-Terminals wechseln zwischen Kommen und Gehen; Gehen beendet eine laufende Pause. Für wiederholte Terminalübertragungen muss derselbe Zeitstempel oder dieselbe `request_id` mitgesendet werden. Scans ohne Zeitstempel und Anfrage-ID gelten jeweils als neue Betätigung. Rückwirkende Offline-Scans werden bei unpassendem Zustand oder Überschneidung abgelehnt und erfordern eine manuelle Korrektur.
|
||||
|
||||
Interne API-Aufrufe bleiben an den authentifizierten Mandanten gebunden. Öffentliche Formulare legen für umgestellte Mitarbeiter eine vollständige Anwesenheit mit Projektzuordnung an. Dokument und Zeit werden gemeinsam in einer Transaktion geschrieben, damit ein Zeitkonflikt keinen halbfertigen Lieferschein hinterlässt. Alte Bearbeitungs- und Projektänderungsaufrufe verweisen auf die vollständige Bearbeitung unter 2.0, damit Pausen oder Freigaben nicht verloren gehen.
|
||||
|
||||
## Technischer Aufbau
|
||||
|
||||
`staff_time_spans` enthält Anwesenheiten und Abwesenheiten mit diskriminierendem Typ, Status und Versionsnummer. Gemeinsam gespeicherte Intervalle erlauben eine einheitliche Prüfung auf Überschneidungen. Zugehörige Tabellen enthalten Pausen, Projektzeiten, Änderungshistorie, Quellverweise sowie aktivierte Mitarbeiter und wiederholungssichere Anfragen. Der Mandantenexport nimmt auch Pausen, Projektzuordnungen und Änderungshistorie mit. Die Dienstschicht liegt unter `backend/src/modules/time-v2`.
|
||||
|
||||
Die API liegt unter `/api/v2/time`. `GET /context` liefert Mitarbeiterauswahl und Rechte, `GET /spans` die Spannen und den aktuellen Zustand, `GET /evaluation` die Auswertung und `GET /spans/:id/history` den Verlauf. `POST /commands` akzeptiert `user_id`, `request_id` und `command`. Bearbeitungen und Statuswechsel enthalten ID und erwartete Versionsnummer. Gleichzeitige Änderungen werden mit HTTP 409 beantwortet. Ein Wiederholungsversuch muss dieselbe Anfrage-ID und identische Daten verwenden. `GET /migration` erzeugt die Vorschau; `POST /activate` verlangt deren Fingerprint.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
Fachliche Tests laufen mit:
|
||||
|
||||
```sh
|
||||
node --import ./backend/node_modules/tsx/dist/loader.mjs --test backend/tests/timeV2.test.ts
|
||||
```
|
||||
|
||||
Die PostgreSQL- und HTTP-Integrationstests benötigen eine ausdrücklich bestimmte, entbehrliche Testdatenbank namens `time_v2_test`:
|
||||
|
||||
```sh
|
||||
TIME_V2_TEST_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/time_v2_test \
|
||||
node --import ./backend/node_modules/tsx/dist/loader.mjs --test backend/tests/timeV2.test.ts
|
||||
```
|
||||
|
||||
Jeder Integrationstest erzeugt ein eigenes Schema und entfernt es anschließend. Ohne die Variable werden diese Integrationstests übersprungen. Getestet werden unter anderem Pausenabzug, Monatsgrenzen, Zeitumstellungen, Feiertage, Migration, Wiederholungen, Parallelzugriffe, Versionskonflikte, Statuswechsel und Mandantentrennung. Zusätzlich sind Backend-Typecheck und Frontend-Build auszuführen.
|
||||
|
||||
Die Browserprüfung verwendet ausschließlich simulierte API-Antworten und prüft den gebauten Frontend-Stand. Sie benötigt eine lokale Playwright-Installation und einen laufenden Frontend-Previewserver:
|
||||
|
||||
```sh
|
||||
PLAYWRIGHT_MODULE=/ABSOLUTER/PFAD/playwright/index.mjs \
|
||||
TIME_V2_FRONTEND_URL=http://127.0.0.1:3148 \
|
||||
node frontend/tests/time-v2.browser.mjs
|
||||
```
|
||||
@@ -478,6 +478,12 @@ const links = computed(() => {
|
||||
icon: "i-heroicons-user-group",
|
||||
children: visibleContactsChildren
|
||||
}] : []),
|
||||
...(featureEnabled("staffTime") ? [{
|
||||
label: "2.0",
|
||||
icon: "i-heroicons-sparkles",
|
||||
defaultOpen: false,
|
||||
children: [{ label: "Zeiterfassung", to: "/v2/time", icon: "i-heroicons-clock" }]
|
||||
}] : []),
|
||||
...(visibleStaffChildren.length > 0 ? [{
|
||||
label: "Mitarbeiter",
|
||||
defaultOpen: false,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<script setup>
|
||||
import dayjs from "dayjs";
|
||||
import { useNow } from "@vueuse/core";
|
||||
import { workingMinutes } from "~/utils/staffTimeSpans";
|
||||
|
||||
const profileStore = useProfileStore();
|
||||
const toast = useToast()
|
||||
const staffTime = useStaffTime()
|
||||
|
||||
const runningTimeInfo = ref({})
|
||||
const clock = useNow({ interval: 1000 })
|
||||
const workingDuration = computed(() => {
|
||||
const minutes = workingMinutes(runningTimeInfo.value, clock.value.getTime())
|
||||
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')} h`
|
||||
})
|
||||
|
||||
const projects = ref([])
|
||||
const platform = ref("default")
|
||||
@@ -51,9 +58,12 @@ const stopStartedTime = async () => {
|
||||
<template>
|
||||
<div v-if="runningTimeInfo.started_at">
|
||||
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
|
||||
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
|
||||
<p>Arbeitszeit: {{ workingDuration }}</p>
|
||||
<p v-if="runningTimeInfo.paused">In Pause</p>
|
||||
<UButton v-if="runningTimeInfo.model === 'spans-v2'" class="mt-2" variant="soft" :to="{ path: '/v2/time', query: { user_id: runningTimeInfo.user_id } }">Anwesenheit bearbeiten</UButton>
|
||||
|
||||
<UFormField
|
||||
v-if="runningTimeInfo.model !== 'spans-v2'"
|
||||
class="mt-2"
|
||||
label="Notizen:"
|
||||
>
|
||||
@@ -62,6 +72,7 @@ const stopStartedTime = async () => {
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
v-if="runningTimeInfo.model !== 'spans-v2'"
|
||||
class="mt-2"
|
||||
label="Projekt:"
|
||||
>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<script setup>
|
||||
import dayjs from "dayjs";
|
||||
import { useNow } from "@vueuse/core";
|
||||
import { workingMinutes } from "~/utils/staffTimeSpans";
|
||||
|
||||
const profileStore = useProfileStore();
|
||||
const toast = useToast()
|
||||
const staffTime = useStaffTime()
|
||||
|
||||
const runningTimeInfo = ref({})
|
||||
const clock = useNow({ interval: 1000 })
|
||||
const workingDuration = computed(() => {
|
||||
const minutes = workingMinutes(runningTimeInfo.value, clock.value.getTime())
|
||||
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')} h`
|
||||
})
|
||||
|
||||
const setupPage = async () => {
|
||||
const rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
|
||||
@@ -47,9 +54,12 @@ const stopStartedTime = async () => {
|
||||
<template>
|
||||
<div v-if="runningTimeInfo.started_at">
|
||||
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
|
||||
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
|
||||
<p>Arbeitszeit: {{ workingDuration }}</p>
|
||||
<p v-if="runningTimeInfo.paused">In Pause</p>
|
||||
<UButton v-if="runningTimeInfo.model === 'spans-v2'" class="mt-2" variant="soft" :to="{ path: '/v2/time', query: { user_id: runningTimeInfo.user_id } }">Anwesenheit bearbeiten</UButton>
|
||||
|
||||
<UFormField
|
||||
v-if="runningTimeInfo.model !== 'spans-v2'"
|
||||
class="mt-2"
|
||||
label="Notizen:"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ const setupPage = async () => {
|
||||
const checks = await Promise.all(profiles.map(async (profile) => {
|
||||
try {
|
||||
const spans = await useNuxtApp().$api(`/api/staff/time/spans?targetUserId=${profile.user_id || profile.id}`)
|
||||
const openSpan = (spans || []).find((s) => !s.endedAt && s.type === "work")
|
||||
const openSpan = (spans || []).find((s) => !s.endedAt && ["work", "pause"].includes(s.type))
|
||||
if (openSpan) return { profile: profile.id }
|
||||
} catch (e) {
|
||||
return null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { staffTimeRows } from '~/utils/staffTimeSpans'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
@@ -14,21 +15,7 @@ export const useStaffTime = () => {
|
||||
const params = new URLSearchParams({ from, to, targetUserId })
|
||||
try {
|
||||
const spans = await $api(`/api/staff/time/spans?${params.toString()}`)
|
||||
return (spans || []).map((span: any) => {
|
||||
const start = $dayjs(span.startedAt)
|
||||
const end = span.endedAt ? $dayjs(span.endedAt) : $dayjs()
|
||||
return {
|
||||
id: span.sourceEventIds && span.sourceEventIds.length > 0 ? span.sourceEventIds[0] : null,
|
||||
eventIds: span.sourceEventIds || [],
|
||||
state: span.status,
|
||||
started_at: span.startedAt,
|
||||
stopped_at: span.endedAt,
|
||||
duration_minutes: end.diff(start, 'minute'),
|
||||
user_id: targetUserId,
|
||||
type: span.type,
|
||||
description: span.description || span.payload?.description || ''
|
||||
}
|
||||
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf())
|
||||
return staffTimeRows(spans || [], targetUserId)
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden:", error)
|
||||
return []
|
||||
|
||||
@@ -79,6 +79,11 @@ async function loadUsers() {
|
||||
|
||||
async function load() {
|
||||
if (!selectedUser.value) return
|
||||
const context = await useNuxtApp().$api('/api/v2/time/context')
|
||||
if (context.profiles.some(p => p.user_id === selectedUser.value && p.activated_at)) {
|
||||
await router.replace({ path: '/v2/time', query: { user_id: selectedUser.value } })
|
||||
return
|
||||
}
|
||||
entries.value = await list({ user_id: selectedUser.value })
|
||||
}
|
||||
|
||||
|
||||
238
frontend/pages/v2/time.vue
Normal file
238
frontend/pages/v2/time.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { timeSpanInput, localTimeInput } from '~/utils/timeSpanInput';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
definePageMeta({ layout: 'default' });
|
||||
const { $api } = useNuxtApp();
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const selected = ref(String(route.query.user_id || auth.user?.id || ''));
|
||||
const context = ref<any>({ profiles: [], projects: [], permissions: {} });
|
||||
const today = dayjs().tz('Europe/Berlin');
|
||||
const from = ref(today.startOf('month').format('YYYY-MM-DD')), to = ref(today.endOf('month').format('YYYY-MM-DD'));
|
||||
const tab = ref('mine'), view = ref('list'), statusFilter = ref('all');
|
||||
const entries = ref<any[]>([]), active = ref<any>(null), account = ref<any>(null), evaluation = ref<any>(null);
|
||||
const busy = ref(false), loading = ref(false), error = ref(''), notice = ref(''), preview = ref<any>(null);
|
||||
const editorElement = ref<HTMLElement | null>(null);
|
||||
const edit = ref<any>(null), history = ref<any[] | null>(null), historyEntry = ref(''), rejection = ref<any>(null), reason = ref('');
|
||||
const now = ref(Date.now());
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let loadSequence = 0;
|
||||
const labels: Record<string, string> = { work: 'Anwesenheit', vacation: 'Urlaub', sick: 'Krankheit', overtime_compensation: 'Freizeitausgleich', holiday: 'Feiertag', other: 'Sonstiges' };
|
||||
const states: Record<string, string> = { factual: 'Erfasst', submitted: 'Eingereicht', approved: 'Genehmigt', rejected: 'Abgelehnt' };
|
||||
const canWrite = computed(() => selected.value === auth.user?.id || context.value.permissions.writeAll);
|
||||
const paused = computed(() => active.value?.breaks?.some((p: any) => !p.ended_at));
|
||||
const shown = computed(() => entries.value.filter(e => statusFilter.value === 'all' || e.status === statusFilter.value));
|
||||
const groups = computed(() => {
|
||||
const map = new Map<string, any[]>();
|
||||
for (const entry of shown.value) {
|
||||
const key = local(entry.started_at).slice(0, 10);
|
||||
map.set(key, [...(map.get(key) || []), entry]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
});
|
||||
const local = (value: string | null, zone = 'Europe/Berlin') => value ? localTimeInput(value, zone) : '';
|
||||
const formatted = (value: string | null) => value ? new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'shortOffset' }).format(new Date(value)) : 'läuft';
|
||||
const hours = (minutes: number) => { const m = Math.round(Math.abs(minutes || 0)); return `${minutes < 0 ? '−' : ''}${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`; };
|
||||
function duration(e: any) {
|
||||
const end = e.ended_at ? Date.parse(e.ended_at) : now.value;
|
||||
return (end - Date.parse(e.started_at) - (e.breaks || []).reduce((n: number, p: any) => n + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0)) / 60000;
|
||||
}
|
||||
function message(e: any) { return e?.data?.error || e?.message || 'Die Aktion konnte nicht ausgeführt werden.'; }
|
||||
async function loadContext() { context.value = await $api('/api/v2/time/context'); }
|
||||
async function load() {
|
||||
if (!selected.value)
|
||||
return;
|
||||
const sequence = ++loadSequence;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const q = new URLSearchParams({ user_id: selected.value, from: from.value, to: to.value });
|
||||
const [result, report] = await Promise.all([$api(`/api/v2/time/spans?${q}`), $api(`/api/v2/time/evaluation?${q}`)]) as any[];
|
||||
if (sequence !== loadSequence)
|
||||
return;
|
||||
entries.value = result.items;
|
||||
active.value = result.active;
|
||||
account.value = result.account;
|
||||
evaluation.value = report.summary;
|
||||
}
|
||||
catch (e) {
|
||||
if (sequence === loadSequence) {
|
||||
error.value = message(e);
|
||||
entries.value = [];
|
||||
active.value = null;
|
||||
account.value = null;
|
||||
evaluation.value = null;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (sequence === loadSequence)
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function perform(fn: () => Promise<any>) {
|
||||
if (busy.value)
|
||||
return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
await fn();
|
||||
}
|
||||
catch (e) {
|
||||
error.value = message(e);
|
||||
}
|
||||
finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function command(cmd: any) {
|
||||
await $api('/api/v2/time/commands', { method: 'POST', body: { user_id: selected.value, request_id: crypto.randomUUID(), command: cmd } });
|
||||
await load();
|
||||
notice.value = 'Zeit gespeichert.';
|
||||
}
|
||||
function live(action: string) { return perform(() => command({ action, at: new Date().toISOString(), ...(action !== 'start' ? { id: active.value.id, version: active.value.version } : {}) })); }
|
||||
function workflow(e: any, action: string) { return perform(() => command({ action, id: e.id, version: e.version })); }
|
||||
function openEdit(e?: any) {
|
||||
history.value = null;
|
||||
const zone = e?.timezone || 'Europe/Berlin';
|
||||
edit.value = { id: e?.id, version: e?.version, type: e?.type || 'work', description: e?.description || '', timezone: zone,
|
||||
started_at: local(e?.started_at || new Date().toISOString(), zone), ended_at: local(e?.ended_at, zone), original_start: e?.started_at, original_end: e?.ended_at,
|
||||
allDay: Boolean(e?.date_from), date_from: e?.date_from || today.format('YYYY-MM-DD'), date_to: e?.date_to ? dayjs.utc(e.date_to).subtract(1, 'day').format('YYYY-MM-DD') : today.format('YYYY-MM-DD'),
|
||||
breaks: (e?.breaks || []).map((p: any) => ({ ...p, started_at: local(p.started_at, zone), ended_at: local(p.ended_at, zone), original_start: p.started_at, original_end: p.ended_at })),
|
||||
projects: (e?.projects || []).map((p: any) => ({ ...p, started_at: local(p.started_at, zone), ended_at: local(p.ended_at, zone), original_start: p.started_at, original_end: p.ended_at })), reason: '' };
|
||||
nextTick(() => editorElement.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||||
}
|
||||
function save() {
|
||||
return perform(async () => {
|
||||
const e = edit.value, zone = e.timezone;
|
||||
const period = (p: any) => ({ started_at: timeSpanInput(p.started_at, zone, p.original_start), ended_at: p.ended_at ? timeSpanInput(p.ended_at, zone, p.original_end) : null });
|
||||
const data = { type: e.type, description: e.description, timezone: zone,
|
||||
...(e.allDay && e.type !== 'work' ? { date_from: e.date_from, date_to: dayjs.utc(e.date_to).add(1, 'day').format('YYYY-MM-DD') } : period(e)),
|
||||
breaks: e.type === 'work' ? e.breaks.map(period) : [], projects: e.type === 'work' ? e.projects.map((p: any) => ({ ...period(p), project_id: Number(p.project_id), notes: p.notes })) : [] };
|
||||
await command({ action: e.id ? 'update' : 'create', id: e.id, version: e.version, data, reason: e.reason });
|
||||
edit.value = null;
|
||||
});
|
||||
}
|
||||
function showHistory(e: any) { return perform(async () => { history.value = await $api(`/api/v2/time/spans/${e.id}/history`) as any[]; historyEntry.value = formatted(e.started_at); }); }
|
||||
function migrationPreview() { return perform(async () => { preview.value = await $api(`/api/v2/time/migration?user_id=${selected.value}`); }); }
|
||||
function activate() {
|
||||
return perform(async () => {
|
||||
await $api('/api/v2/time/activate', { method: 'POST', body: { user_id: selected.value, fingerprint: preview.value.fingerprint } });
|
||||
preview.value = null;
|
||||
await loadContext();
|
||||
await load();
|
||||
notice.value = 'Zeiterfassung 2.0 aktiviert. Alle Erfassungswege verwenden jetzt die gespeicherten Spannen.';
|
||||
});
|
||||
}
|
||||
function segments(e: any) {
|
||||
const end = e.ended_at ? Date.parse(e.ended_at) : now.value, start = Date.parse(e.started_at), length = Math.max(1, end - start);
|
||||
return (e.breaks || []).map((p: any) => ({ left: `${100 * (Date.parse(p.started_at) - start) / length}%`, width: `${100 * ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)) / length}%` }));
|
||||
}
|
||||
watch(selected, () => { edit.value = null; preview.value = null; history.value = null; rejection.value = null; load(); });
|
||||
watch(tab, value => { if (value === 'mine')
|
||||
selected.value = auth.user?.id || selected.value; statusFilter.value = value === 'team' ? 'submitted' : 'all'; });
|
||||
onMounted(async () => { timer = setInterval(() => { now.value = Date.now(); }, 1000); await perform(async () => { await loadContext(); await load(); }); });
|
||||
onUnmounted(() => { if (timer)
|
||||
clearInterval(timer); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="time-v2">
|
||||
<template #header><UDashboardNavbar title="Zeiterfassung 2.0" /></template>
|
||||
<template #body>
|
||||
<div class="mx-auto w-full max-w-6xl space-y-6 pb-12">
|
||||
<div class="flex flex-wrap gap-2" role="tablist" aria-label="Zeitansichten">
|
||||
<UButton :variant="tab === 'mine' ? 'solid' : 'soft'" @click="tab='mine'">Meine Zeiten</UButton>
|
||||
<UButton v-if="context.permissions.readAll" :variant="tab === 'team' ? 'solid' : 'soft'" @click="tab='team'">Team & Freigaben</UButton>
|
||||
<UButton :variant="tab === 'evaluation' ? 'solid' : 'soft'" @click="tab='evaluation'">Auswertung</UButton>
|
||||
</div>
|
||||
<p v-if="error" role="alert" class="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:text-red-200">{{ error }}</p>
|
||||
<p v-if="notice" role="status" class="rounded-lg bg-green-50 p-4 text-green-800 dark:bg-green-950 dark:text-green-200">{{ notice }}</p>
|
||||
<form class="flex flex-wrap items-end gap-4" @submit.prevent="load">
|
||||
<label v-if="context.permissions.readAll" class="field">Mitarbeiter<select v-model="selected" :disabled="busy"><option v-for="p in context.profiles" :key="p.user_id" :value="p.user_id">{{ p.full_name }}</option></select></label>
|
||||
<label class="field">Von<input v-model="from" type="date" required></label>
|
||||
<label class="field">Bis<input v-model="to" type="date" required></label>
|
||||
<UButton type="submit" :loading="loading" :disabled="busy">Anzeigen</UButton>
|
||||
</form>
|
||||
<section v-if="!account && !loading" class="rounded-xl border border-amber-300 p-5 space-y-3">
|
||||
<h2 class="font-semibold">Zeiterfassung 2.0 aktivieren</h2>
|
||||
<p>Vor der Umstellung werden bestehende Zeiten und Projektzuordnungen geprüft. Nach der Aktivierung nutzen auch die Mobile-App und die bisherigen Zeitanzeigen dieses Modell.</p>
|
||||
<UButton v-if="context.permissions.activate" :loading="busy" @click="migrationPreview">Übernahme prüfen</UButton>
|
||||
<p v-else>Die Aktivierung erfolgt durch einen Administrator oder eine Person mit Aktivierungsberechtigung.</p>
|
||||
<NuxtLink to="/staff/time" class="underline block">Bisherige Zeiterfassung öffnen</NuxtLink>
|
||||
<div v-if="preview" class="space-y-3">
|
||||
<p>{{ preview.count }} Einträge · {{ hours(preview.totalMinutes) }} Arbeitszeit zur Übernahme</p>
|
||||
<ul v-if="preview.issues.length" class="list-disc pl-5"><li v-for="issue in preview.issues" :key="issue">{{ issue }}</li></ul>
|
||||
<details v-if="preview.candidates.length"><summary class="cursor-pointer">Einträge prüfen</summary><ul class="max-h-64 overflow-auto text-sm"><li v-for="(c,i) in preview.candidates" :key="i">{{ labels[c.span.type] }} · {{ formatted(c.span.started_at) }} – {{ formatted(c.span.ended_at) }} · {{ states[c.span.status] }}</li></ul></details>
|
||||
<UButton :disabled="preview.issues.length > 0 || busy" @click="activate">Geprüfte Daten übernehmen und aktivieren</UButton>
|
||||
</div>
|
||||
</section>
|
||||
<template v-if="account">
|
||||
<section v-if="tab !== 'evaluation'" class="rounded-xl border border-default p-5 space-y-4">
|
||||
<div class="flex flex-wrap justify-between gap-4">
|
||||
<div><p class="text-sm text-muted">Aktueller Status</p><h2 class="text-xl font-semibold">{{ active ? (active.type === 'work' ? paused ? 'In Pause' : 'Anwesend' : labels[active.type]) : 'Nicht anwesend' }}</h2>
|
||||
<p v-if="active" class="text-sm">Seit {{ formatted(active.started_at) }} · {{ hours(duration(active)) }} {{ active.type === 'work' ? 'Arbeitszeit' : '' }}</p></div>
|
||||
<div v-if="canWrite" class="flex flex-wrap items-center gap-2">
|
||||
<UButton v-if="!active" :disabled="busy || loading" @click="live('start')">Kommen</UButton>
|
||||
<template v-else-if="active.type === 'work'"><UButton :disabled="busy || loading" variant="soft" @click="live(paused ? 'resume' : 'pause')">{{ paused ? 'Pause beenden' : 'Pause beginnen' }}</UButton><UButton :disabled="busy || loading" @click="live('stop')">Gehen</UButton></template>
|
||||
<UButton v-else :disabled="busy" @click="openEdit(active)">Eintrag abschließen</UButton>
|
||||
<UButton variant="outline" :disabled="busy" @click="openEdit()">Manuell erfassen</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="tab === 'evaluation' && evaluation" class="space-y-4">
|
||||
<p class="text-sm text-muted">Abgeschlossene Zeiten · Zeitzone Europe/Berlin · Freizeitausgleich und Sonstiges erzeugen keine Zeitgutschrift.</p>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div v-for="[label,key] in [['Sollzeit','timeSpanWorkingMinutes'],['Anwesenheit','presenceMinutes'],['Pausen','breakMinutes'],['Eingereicht inkl. genehmigt','sumWorkingMinutesSubmitted'],['Genehmigte Arbeitszeit','sumWorkingMinutesApproved'],['Urlaub','sumWorkingMinutesVacationDays'],['Krankheit','sumWorkingMinutesSickDays'],['Feiertagsausgleich','sumWorkingMinutesRecreationDays'],['Freizeitausgleich','sumOvertimeCompensationMinutes'],['Sonstiges','sumOtherMinutes'],['Saldo eingereicht','saldoSubmitted'],['Saldo genehmigt','saldoApproved']]" :key="key" class="rounded-xl border border-default p-4"><p class="text-sm text-muted">{{ label }}</p><p class="text-xl font-semibold tabular-nums">{{ hours(evaluation[key]) }}</p></div>
|
||||
</div>
|
||||
<p>Urlaub: {{ Number(evaluation.sumVacationDays).toLocaleString('de-DE') }} Arbeitstage · Krankheit: {{ Number(evaluation.sumSickDays).toLocaleString('de-DE') }} Arbeitstage · Feiertage: {{ evaluation.sumRecreationDays }}</p>
|
||||
</section>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><label class="field">Status<select v-model="statusFilter"><option value="all">Alle</option><option v-for="(label,key) in states" :key="key" :value="key">{{ label }}</option></select></label><div class="flex gap-2"><UButton :variant="view === 'list' ? 'solid' : 'soft'" @click="view='list'">Liste</UButton><UButton :variant="view === 'timeline' ? 'solid' : 'soft'" @click="view='timeline'">Zeitstrahl</UButton></div></div>
|
||||
<p v-if="!shown.length && !loading" class="py-8 text-center text-muted">Keine Einträge für diese Auswahl.</p>
|
||||
<section v-for="[date,items] in groups" :key="date" class="space-y-3">
|
||||
<h2 class="font-semibold">{{ dayjs(date).format('DD.MM.YYYY') }}</h2>
|
||||
<article v-for="e in items" :key="e.id" class="rounded-xl border border-default p-4 space-y-3">
|
||||
<div class="flex flex-wrap justify-between gap-3"><div><strong>{{ labels[e.type] }}</strong><span class="ml-3 text-sm">{{ states[e.status] }}</span><p class="text-sm">{{ formatted(e.started_at) }} – {{ formatted(e.ended_at) }}</p><p v-if="e.description" class="whitespace-pre-wrap text-sm text-muted">{{ e.description }}</p></div><strong class="tabular-nums">{{ hours(duration(e)) }}</strong></div>
|
||||
<div v-if="view === 'timeline'" class="space-y-1"><div class="relative h-7 overflow-hidden rounded bg-primary/70" role="img" :aria-label="`${labels[e.type]} mit ${e.breaks.length} Pausen`"><span v-for="(style,i) in segments(e)" :key="i" :style="style" class="absolute top-0 h-full bg-amber-400" /></div><p class="text-xs text-muted">Grün: Anwesenheit · Gelb: Pause · Skala je Eintrag</p></div>
|
||||
<p v-for="(p,i) in e.breaks" :key="i" class="text-sm">Pause: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }}</p>
|
||||
<p v-for="p in e.projects" :key="p.id" class="text-sm">Projekt {{ context.projects.find((x:any) => Number(x.id) === Number(p.project_id))?.name || p.project_id }}: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }} · {{ p.notes }}</p>
|
||||
<div class="flex flex-wrap gap-2"><UButton v-if="canWrite" size="sm" variant="soft" :disabled="busy" @click="openEdit(e)">Bearbeiten</UButton><UButton v-if="canWrite && e.ended_at && ['factual','rejected'].includes(e.status)" size="sm" :disabled="busy" @click="workflow(e,'submit')">Einreichen</UButton><template v-if="context.permissions.approve && e.status === 'submitted'"><UButton size="sm" :disabled="busy" @click="workflow(e,'approve')">Genehmigen</UButton><UButton size="sm" color="error" variant="soft" :disabled="busy" @click="rejection=e; reason=''">Ablehnen</UButton></template><UButton size="sm" variant="ghost" :disabled="busy" @click="showHistory(e)">Verlauf</UButton></div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
<section v-if="rejection" class="rounded-xl border border-default p-5 space-y-3"><h2 class="font-semibold">Eintrag ablehnen</h2><label class="field">Begründung<textarea v-model="reason" /></label><div class="flex gap-2"><UButton :disabled="!reason.trim() || busy" @click="perform(async () => { await command({action:'reject',id:rejection.id,version:rejection.version,reason}); rejection=null })">Ablehnung speichern</UButton><UButton variant="ghost" @click="rejection=null">Abbrechen</UButton></div></section>
|
||||
<section v-if="edit" ref="editorElement" class="rounded-xl border-2 border-primary p-5 space-y-4">
|
||||
<h2 class="text-lg font-semibold">{{ edit.id ? 'Eintrag bearbeiten' : 'Zeit erfassen' }}</h2>
|
||||
<p v-if="edit.id" class="text-sm">Änderungen setzen eine bestehende Einreichung oder Genehmigung zurück.</p>
|
||||
<form class="space-y-4" @submit.prevent="save">
|
||||
<div class="grid gap-4 md:grid-cols-2"><label class="field">Art<select v-model="edit.type"><option v-for="(label,key) in labels" :key="key" :value="key">{{ label }}</option></select></label><label class="field">Zeitzone<input v-model="edit.timezone" required placeholder="Europe/Berlin"></label></div>
|
||||
<label v-if="edit.type !== 'work'" class="flex items-center gap-2"><input v-model="edit.allDay" type="checkbox">Ganztägig</label>
|
||||
<div v-if="edit.allDay && edit.type !== 'work'" class="grid gap-4 md:grid-cols-2"><label class="field">Erster Tag<input v-model="edit.date_from" type="date" required></label><label class="field">Letzter Tag (einschließlich)<input v-model="edit.date_to" type="date" :min="edit.date_from" required></label></div>
|
||||
<div v-else class="grid gap-4 md:grid-cols-2"><label class="field">Beginn<input v-model="edit.started_at" type="datetime-local" required></label><label class="field">Ende (leer = läuft)<input v-model="edit.ended_at" type="datetime-local"></label></div>
|
||||
<label class="field">Beschreibung<textarea v-model="edit.description" maxlength="10000" /></label>
|
||||
<template v-if="edit.type === 'work'">
|
||||
<fieldset class="space-y-3"><legend class="font-semibold">Pausen</legend><div v-for="(p,i) in edit.breaks" :key="i" class="flex flex-wrap items-end gap-3"><label class="field">Beginn<input v-model="p.started_at" type="datetime-local" required></label><label class="field">Ende<input v-model="p.ended_at" type="datetime-local"></label><UButton variant="ghost" color="error" @click="edit.breaks.splice(i,1)">Entfernen</UButton></div><UButton variant="soft" @click="edit.breaks.push({started_at:edit.started_at,ended_at:edit.ended_at})">Pause hinzufügen</UButton></fieldset>
|
||||
<fieldset class="space-y-3"><legend class="font-semibold">Projektzuordnungen</legend><div v-for="(p,i) in edit.projects" :key="i" class="grid gap-3 rounded-lg border border-default p-3 md:grid-cols-2"><label class="field">Projekt<select v-model="p.project_id" required><option value="">Bitte wählen</option><option v-for="project in context.projects" :key="project.id" :value="project.id">{{ project.name }}</option></select></label><label class="field">Notiz<input v-model="p.notes"></label><label class="field">Beginn<input v-model="p.started_at" type="datetime-local" required></label><label class="field">Ende<input v-model="p.ended_at" type="datetime-local" required></label><UButton variant="ghost" color="error" @click="edit.projects.splice(i,1)">Zuordnung entfernen</UButton></div><UButton variant="soft" @click="edit.projects.push({project_id:'',started_at:edit.started_at,ended_at:edit.ended_at,notes:''})">Projekt hinzufügen</UButton></fieldset>
|
||||
</template>
|
||||
<label v-if="edit.id" class="field">Korrekturgrund<input v-model="edit.reason" required></label>
|
||||
<div class="flex gap-2"><UButton type="submit" :loading="busy">Speichern</UButton><UButton variant="ghost" :disabled="busy" @click="edit=null">Abbrechen</UButton></div>
|
||||
</form>
|
||||
</section>
|
||||
<section v-if="history" class="rounded-xl border border-default p-5 space-y-3"><div class="flex justify-between"><h2 class="font-semibold">Verlauf · {{ historyEntry }}</h2><UButton variant="ghost" @click="history=null">Schließen</UButton></div><details v-for="h in history" :key="h.id" class="border-b border-default py-2"><summary class="cursor-pointer">{{ formatted(h.created_at) }} · {{ ({create:'Erstellt',start:'Gekommen',stop:'Gegangen',pause:'Pause begonnen',resume:'Pause beendet',update:'Korrigiert',submit:'Eingereicht',approve:'Genehmigt',reject:'Abgelehnt',migration:'Übernommen'} as any)[h.action] || h.action }} · {{ context.profiles.find((p:any) => p.user_id === h.actor_id)?.full_name || h.actor_id }}</summary><p class="py-2">{{ h.reason }}</p><div class="grid gap-4 md:grid-cols-2"><div v-for="[label,snapshot] in [['Vorher',h.before_data],['Nachher',h.after_data]]" :key="label" class="text-sm"><strong>{{ label }}</strong><template v-if="snapshot"><p>{{ labels[snapshot.type] }} · {{ states[snapshot.status] }}</p><p>{{ formatted(snapshot.started_at) }} – {{ formatted(snapshot.ended_at) }}</p><p>{{ snapshot.description }}</p><p v-for="(p,i) in snapshot.breaks" :key="i">Pause: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }}</p><p v-for="(p,i) in snapshot.projects" :key="i">Projekt {{ p.project_id }}: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }} · {{ p.notes }}</p></template><p v-else>—</p></div></div></details></section>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field { display: flex; flex-direction: column; gap: .35rem; font-size: .875rem; }
|
||||
.field input, .field select, .field textarea { border: 1px solid var(--ui-border, #cbd5e1); border-radius: .5rem; padding: .5rem .75rem; background: var(--ui-bg, white); color: inherit; min-width: 0; }
|
||||
.field textarea { min-height: 5rem; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus { outline: 2px solid var(--ui-primary, #69c350); outline-offset: 2px; }
|
||||
</style>
|
||||
73
frontend/tests/time-v2.browser.mjs
Normal file
73
frontend/tests/time-v2.browser.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
// Run against a built frontend with PLAYWRIGHT_MODULE pointing to an installed Playwright package.
|
||||
// All API calls are isolated fixtures; no real account or application data is used.
|
||||
import assert from 'node:assert/strict'
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright')
|
||||
const base = process.env.TIME_V2_FRONTEND_URL || 'http://127.0.0.1:3148'
|
||||
const browser = await chromium.launch({headless:true})
|
||||
const page = await browser.newPage({viewport:{width:1440,height:1100}})
|
||||
const errors = []
|
||||
page.on('pageerror',e => errors.push(e.message))
|
||||
const user = '11111111-1111-4111-8111-111111111111'
|
||||
const month = new Date().toISOString().slice(0,7)
|
||||
const start = `${month}-01T06:00:00.000Z`
|
||||
let item = {id:'22222222-2222-4222-8222-222222222222',user_id:user,type:'work',description:'Test Anwesenheit',started_at:start,
|
||||
ended_at:`${month}-01T14:30:00.000Z`,date_from:null,date_to:null,timezone:'Europe/Berlin',status:'factual',version:1,
|
||||
breaks:[{started_at:`${month}-01T10:00:00.000Z`,ended_at:`${month}-01T10:30:00.000Z`}],projects:[]}
|
||||
const profile={id:user,user_id:user,full_name:'Anna Beispiel',first_name:'Anna',last_name:'Beispiel',pinned_on_navigation:[],temp_config:{},role:{rights:[]}}
|
||||
const tenant={id:1,name:'Testbetrieb',extraModules:[],features:{},locked:'none'}
|
||||
const commands=[]
|
||||
await page.addInitScript(() => localStorage.setItem('token','fixture-only'))
|
||||
await page.route('**/*',async route => {
|
||||
const url=new URL(route.request().url())
|
||||
if(url.origin!==new URL(base).origin) return route.abort()
|
||||
if(!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/auth/')) return route.continue()
|
||||
let body=[]
|
||||
if(url.pathname==='/api/me') body={user:{id:user,email:'anna@example.test',is_admin:true},profile,permissions:[],tenants:[tenant],activeTenant:1}
|
||||
else if(url.pathname==='/api/tenant') body=tenant
|
||||
else if(url.pathname==='/api/tenant/profiles') body={data:[profile]}
|
||||
else if(url.pathname==='/api/v2/time/context') body={profiles:[{...profile,activated_at:start}],projects:[{id:1,name:'Projekt Nord'}],permissions:{readAll:true,writeAll:true,approve:true,activate:true}}
|
||||
else if(url.pathname==='/api/v2/time/spans') body={items:[item],active:item.ended_at ? null : item,account:{activated_at:start}}
|
||||
else if(url.pathname==='/api/v2/time/evaluation') body={summary:{timeSpanWorkingMinutes:480,presenceMinutes:510,breakMinutes:30,sumWorkingMinutesApproved:480,sumWorkingMinutesSubmitted:480,saldoApproved:0,saldoSubmitted:0,sumVacationDays:0,sumSickDays:0,sumRecreationDays:0}}
|
||||
else if(url.pathname==='/api/v2/time/commands') {
|
||||
const data=route.request().postDataJSON();commands.push(data)
|
||||
const c=data.command
|
||||
if(c.action==='update') item={...item,...c.data,status:'factual',version:item.version+1}
|
||||
else if(c.action==='submit') item={...item,status:'submitted',version:item.version+1}
|
||||
else if(c.action==='approve') item={...item,status:'approved',version:item.version+1}
|
||||
body=item
|
||||
}
|
||||
else if(url.pathname.endsWith('/history')) body=[{id:'history-1',span_id:item.id,actor_id:user,action:'create',reason:'',before_data:null,after_data:item,created_at:start}]
|
||||
return route.fulfill({contentType:'application/json',body:JSON.stringify(body)})
|
||||
})
|
||||
try {
|
||||
await page.goto(`${base}/v2/time`,{waitUntil:'networkidle'})
|
||||
await page.getByRole('button',{name:'Manuell erfassen',exact:true}).waitFor()
|
||||
await page.getByRole('button',{name:'Zeitstrahl',exact:true}).click()
|
||||
await page.getByRole('img',{name:'Anwesenheit mit 1 Pausen'}).waitFor()
|
||||
await page.getByRole('button',{name:'Bearbeiten',exact:true}).click()
|
||||
await page.getByLabel('Beschreibung',{exact:true}).fill('Korrigierte Anwesenheit')
|
||||
await page.getByLabel('Korrekturgrund',{exact:true}).fill('Notiz korrigiert')
|
||||
await page.getByRole('button',{name:'Speichern',exact:true}).click()
|
||||
await page.getByText('Zeit gespeichert.',{exact:true}).waitFor()
|
||||
assert.equal(commands.at(-1).command.data.breaks.length,1)
|
||||
assert.equal(commands.at(-1).command.version,1)
|
||||
assert.equal(commands.at(-1).command.reason,'Notiz korrigiert')
|
||||
await page.getByRole('button',{name:'Einreichen',exact:true}).click()
|
||||
await page.getByRole('button',{name:'Genehmigen',exact:true}).click()
|
||||
await page.getByRole('button',{name:'Verlauf',exact:true}).click()
|
||||
await page.getByText('Verlauf ·',{exact:false}).waitFor()
|
||||
await page.getByRole('button',{name:'Schließen',exact:true}).click()
|
||||
await page.getByRole('button',{name:'Auswertung',exact:true}).click()
|
||||
await page.getByText('Saldo genehmigt',{exact:true}).waitFor()
|
||||
await page.screenshot({path:process.env.TIME_V2_SCREENSHOT_DESKTOP || '/tmp/fedeo-time-v2-desktop.png',fullPage:true})
|
||||
await page.setViewportSize({width:390,height:844})
|
||||
await page.getByRole('button',{name:'Meine Zeiten',exact:true}).click()
|
||||
await page.getByRole('button',{name:'Manuell erfassen',exact:true}).click()
|
||||
await page.getByLabel(/^Art/).selectOption('vacation')
|
||||
await page.getByLabel('Ganztägig',{exact:true}).check()
|
||||
await page.getByLabel('Erster Tag',{exact:true}).waitFor()
|
||||
await page.screenshot({path:process.env.TIME_V2_SCREENSHOT_MOBILE || '/tmp/fedeo-time-v2-mobile.png',fullPage:true})
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth>window.innerWidth),false,'Mobile layout overflows')
|
||||
assert.deepEqual(errors,[],'Browser runtime errors')
|
||||
console.log('Time 2.0 browser checks passed: editing with pauses, submission, approval, history, evaluation, mobile absence form.')
|
||||
} catch (error) { await page.screenshot({path:'/tmp/fedeo-time-v2-error.png',fullPage:true}); throw error } finally {await browser.close()}
|
||||
32
frontend/utils/staffTimeSpans.ts
Normal file
32
frontend/utils/staffTimeSpans.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Collapse legacy-compatible work segments into complete persisted attendances for widgets. */
|
||||
export function staffTimeRows(spans: any[], userId: string, now = Date.now()) {
|
||||
const seen = new Set<string>();
|
||||
return (spans || []).filter(span => {
|
||||
if (span.model !== 'spans-v2') return true;
|
||||
if (seen.has(span.id)) return false;
|
||||
seen.add(span.id);
|
||||
return true;
|
||||
}).map(span => {
|
||||
const s = span.attendance;
|
||||
if (s) {
|
||||
const entry = {
|
||||
id: s.id, eventIds: [s.id], state: s.status, started_at: s.started_at,
|
||||
stopped_at: s.ended_at, user_id: s.user_id, type: s.type, description: s.description,
|
||||
breaks: s.breaks, paused: s.breaks.some((p: any) => !p.ended_at), model: 'spans-v2',
|
||||
};
|
||||
return { ...entry, duration_minutes: workingMinutes(entry, now) };
|
||||
}
|
||||
return {
|
||||
id: span.sourceEventIds?.[0] || null, eventIds: span.sourceEventIds || [], state: span.status,
|
||||
started_at: span.startedAt, stopped_at: span.endedAt, user_id: userId, type: span.type,
|
||||
description: span.description || span.payload?.description || '',
|
||||
duration_minutes: Math.floor(((span.endedAt ? Date.parse(span.endedAt) : now) - Date.parse(span.startedAt)) / 60000),
|
||||
};
|
||||
}).sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at));
|
||||
}
|
||||
export function workingMinutes(entry: any, now = Date.now()) {
|
||||
if (!entry.started_at) return 0;
|
||||
const end = entry.stopped_at ? Date.parse(entry.stopped_at) : now;
|
||||
const pauses = (entry.breaks || []).reduce((total: number, p: any) => total + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0);
|
||||
return Math.max(0, Math.floor((end - Date.parse(entry.started_at) - pauses) / 60000));
|
||||
}
|
||||
29
frontend/utils/timeSpanInput.ts
Normal file
29
frontend/utils/timeSpanInput.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** Use Intl directly: converting repeated hours through a local Date loses their UTC offset. */
|
||||
export function localTimeInput(value: string, zone = 'Europe/Berlin') {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(new Date(value));
|
||||
const get = (type: string) => parts.find(part => part.type === type)!.value;
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`;
|
||||
}
|
||||
|
||||
/** Preserve existing instants. Require an explicit UTC input for ambiguous new local times. */
|
||||
export function timeSpanInput(value: string, zone: string, original?: string | null) {
|
||||
if (original && localTimeInput(original, zone) === value) return original;
|
||||
const wall = Date.parse(`${value}:00Z`);
|
||||
if (!Number.isFinite(wall)) throw new Error('Ungültiger Zeitpunkt.');
|
||||
// Sample both sides of a transition; supports non-hour offsets and 30-minute DST changes.
|
||||
const offsets = new Set<number>();
|
||||
for (let hours = -36; hours <= 36; hours += 6) {
|
||||
const sample = wall + hours * 3600000;
|
||||
offsets.add(Date.parse(`${localTimeInput(new Date(sample).toISOString(), zone)}:00Z`) - sample);
|
||||
}
|
||||
const matches = [...offsets].map(offset => wall - offset)
|
||||
.filter(candidate => localTimeInput(new Date(candidate).toISOString(), zone) === value);
|
||||
if (!matches.length) throw new Error('Diese Ortszeit existiert nicht. Bitte Zeitumstellung beachten.');
|
||||
if (matches.length > 1) {
|
||||
throw new Error('Diese Ortszeit kommt zweimal vor. Bitte Zeitzone UTC wählen und den eindeutigen UTC-Zeitpunkt eingeben.');
|
||||
}
|
||||
return new Date(matches[0]).toISOString();
|
||||
}
|
||||
@@ -39,6 +39,8 @@ function getStateLabel(state: string): string {
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
if (type === 'pause') return 'Pause';
|
||||
if (type === 'overtime_compensation') return 'Freizeitausgleich';
|
||||
if (type === 'vacation') return 'Urlaub';
|
||||
if (type === 'sick') return 'Krankheit';
|
||||
if (type === 'holiday') return 'Feiertag';
|
||||
@@ -133,6 +135,17 @@ export default function TimeTrackingScreen() {
|
||||
}
|
||||
}, [active, currentUserId, token, load]);
|
||||
|
||||
async function onPause() {
|
||||
if (!token || !currentUserId || !active) return;
|
||||
setActionLoading(true); setError(null);
|
||||
try {
|
||||
await createStaffTimeEvent(token, { eventtype: active.paused ? 'pause_end' : 'pause_start',
|
||||
eventtime: new Date().toISOString(), user_id: currentUserId });
|
||||
await load(false);
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Pause konnte nicht gespeichert werden.'); }
|
||||
finally { setActionLoading(false); }
|
||||
}
|
||||
|
||||
async function onSubmit(entry: StaffTimeSpan) {
|
||||
if (!token || !entry.eventIds?.length) return;
|
||||
setActionLoading(true);
|
||||
@@ -206,10 +219,15 @@ export default function TimeTrackingScreen() {
|
||||
<View style={styles.statusCard}>
|
||||
<Text style={styles.statusLabel}>Aktive Zeit</Text>
|
||||
<Text style={styles.statusValue}>
|
||||
{active ? `Läuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
|
||||
{active ? `${active.paused ? 'In Pause' : 'Läuft'} seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
|
||||
</Text>
|
||||
|
||||
<View style={styles.statusActions}>
|
||||
{active?.model === 'spans-v2' && active.type === 'work' ? (
|
||||
<Pressable style={[styles.startButton, actionLoading ? styles.buttonDisabled : null]} onPress={onPause} disabled={actionLoading}>
|
||||
<Text style={styles.startButtonText}>{active.paused ? 'Pause beenden' : 'Pause beginnen'}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{active ? (
|
||||
<Pressable
|
||||
style={[styles.stopButton, actionLoading ? styles.buttonDisabled : null]}
|
||||
|
||||
@@ -34,6 +34,8 @@ export type TenantProfile = {
|
||||
};
|
||||
|
||||
export type StaffTimeSpan = {
|
||||
paused?: boolean;
|
||||
model?: string;
|
||||
id: string | null;
|
||||
eventIds: string[];
|
||||
state: string;
|
||||
@@ -608,8 +610,22 @@ export async function fetchStaffTimeSpans(
|
||||
const query = targetUserId ? `?targetUserId=${encodeURIComponent(targetUserId)}` : '';
|
||||
const spans = await apiRequest<any[]>(`/api/staff/time/spans${query}`, { token });
|
||||
|
||||
return (spans || [])
|
||||
const seen = new Set<string>();
|
||||
return (spans || []).filter((span) => {
|
||||
if (span.model !== 'spans-v2') return true;
|
||||
if (seen.has(span.id)) return false;
|
||||
seen.add(span.id); return true;
|
||||
})
|
||||
.map((span) => {
|
||||
if (span.attendance) {
|
||||
const s = span.attendance;
|
||||
const end = s.ended_at ? Date.parse(s.ended_at) : Date.now();
|
||||
const pauseMs = s.breaks.reduce((n: number, p: any) => n + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0);
|
||||
return { id: s.id, eventIds: [s.id], state: s.status, started_at: s.started_at,
|
||||
stopped_at: s.ended_at, duration_minutes: Math.floor((end - Date.parse(s.started_at) - pauseMs) / 60000),
|
||||
user_id: s.user_id, type: s.type, description: s.description,
|
||||
paused: s.breaks.some((p: any) => !p.ended_at), model: 'spans-v2' } as StaffTimeSpan;
|
||||
}
|
||||
const started = span.startedAt ? new Date(span.startedAt) : null;
|
||||
const ended = span.endedAt ? new Date(span.endedAt) : new Date();
|
||||
const durationMinutes =
|
||||
|
||||
Reference in New Issue
Block a user