feat(zeit): Check-In/Check-Out + Kimai-Sync-Naht (ENTWICKLUN-9)
Arbeitsantritt = bestehender Lock-in (setzt checkedInAt); neu dazu: - Check-Out / Feierabend: dayEntry.checkedOutAt + Endpoint dayEntry.checkOut; Heute-Seite zeigt „Arbeitsantritt HH:MM" und einen „Feierabend / Check-Out"- Button, danach Antritt/Feierabend/Dauer. - Kimai-Anbindung (config-gated, best-effort, nicht blockierend): lib/kimai.ts mit clockIn/clockOut. Check-In startet ein Kimai-Timesheet, Check-Out stoppt es; ID + Sync-Status auf dem dayEntry (kimaiTimesheetId, kimaiSynced). No-Op solange KIMAI_BASE_URL/API_TOKEN/PROJECT_ID/ACTIVITY_ID nicht gesetzt. Migration 0014 (checkedOutAt, kimaiTimesheetId, kimaiSynced). Env-Vars als optional ergänzt + in .env.example dokumentiert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9942cd8e88
commit
5bbb4abffa
9 files changed
+3542
-2
No files matched your search
@@ -22,3 +22,12 @@ NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
PORT=3000
|
||||
PUBLIC_BASE_URL=http://localhost:5173
|
||||
|
||||
# --- Kimai-Sync (optional, ENTWICKLUN-9) ---
|
||||
# Nur wenn ALLE vier gesetzt sind, synct ebw beim Check-In/Out nach Kimai
|
||||
# (sonst No-Op). API-Token in Kimai unter Benutzerprofil → API-Zugriff.
|
||||
# PROJECT/ACTIVITY = numerische IDs des Default-Projekts/-Tätigkeit in Kimai.
|
||||
# KIMAI_BASE_URL=https://kimai.tool-ctrl.de
|
||||
# KIMAI_API_TOKEN=
|
||||
# KIMAI_PROJECT_ID=
|
||||
# KIMAI_ACTIVITY_ID=
|
||||
@@ -19,6 +19,13 @@ const envSchema = z.object({
|
||||
CRON_BEARER_TOKEN: z.string().min(8),
|
||||
|
||||
PUBLIC_BASE_URL: z.string().url().default('http://localhost:5173'),
|
||||
|
||||
// Kimai-Anbindung (optional). Erst wenn alle gesetzt sind, wird beim
|
||||
// Check-In/Out wirklich nach Kimai synchronisiert; sonst No-Op.
|
||||
KIMAI_BASE_URL: z.string().url().optional(),
|
||||
KIMAI_API_TOKEN: z.string().min(1).optional(),
|
||||
KIMAI_PROJECT_ID: z.coerce.number().int().optional(),
|
||||
KIMAI_ACTIVITY_ID: z.coerce.number().int().optional(),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Kimai-Anbindung (optional). Nur wenn KIMAI_BASE_URL + KIMAI_API_TOKEN +
|
||||
// KIMAI_PROJECT_ID + KIMAI_ACTIVITY_ID gesetzt sind, wird wirklich synchronisiert;
|
||||
// sonst No-Op. Alle Calls sind best-effort — ein Kimai-Ausfall darf den
|
||||
// ebw-Check-In/Out NIE blockieren.
|
||||
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
type KimaiConfig = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
projectId: number;
|
||||
activityId: number;
|
||||
};
|
||||
|
||||
function kimaiConfig(): KimaiConfig | null {
|
||||
const env = loadEnv();
|
||||
if (
|
||||
env.KIMAI_BASE_URL &&
|
||||
env.KIMAI_API_TOKEN &&
|
||||
env.KIMAI_PROJECT_ID !== undefined &&
|
||||
env.KIMAI_ACTIVITY_ID !== undefined
|
||||
) {
|
||||
return {
|
||||
baseUrl: env.KIMAI_BASE_URL.replace(/\/$/, ''),
|
||||
token: env.KIMAI_API_TOKEN,
|
||||
projectId: env.KIMAI_PROJECT_ID,
|
||||
activityId: env.KIMAI_ACTIVITY_ID,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function kimaiEnabled(): boolean {
|
||||
return kimaiConfig() !== null;
|
||||
}
|
||||
|
||||
async function safeText(res: Response): Promise<string> {
|
||||
try {
|
||||
return (await res.text()).slice(0, 300);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet ein laufendes Timesheet in Kimai (Clock-In). Liefert die Kimai-
|
||||
* Timesheet-ID zurück oder null (nicht konfiguriert / Fehler).
|
||||
*/
|
||||
export async function kimaiClockIn(opts: { begin: Date; description?: string }): Promise<number | null> {
|
||||
const cfg = kimaiConfig();
|
||||
if (!cfg) {
|
||||
logger.debug('kimai not configured — clock-in skipped');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${cfg.baseUrl}/api/timesheets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.token}` },
|
||||
body: JSON.stringify({
|
||||
begin: opts.begin.toISOString(),
|
||||
project: cfg.projectId,
|
||||
activity: cfg.activityId,
|
||||
description: opts.description ?? 'ebw Check-In',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn({ status: res.status, body: await safeText(res) }, 'kimai clock-in failed');
|
||||
return null;
|
||||
}
|
||||
const data = (await res.json()) as { id?: number };
|
||||
return typeof data.id === 'number' ? data.id : null;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'kimai clock-in error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppt das laufende Timesheet in Kimai (Clock-Out). Best-effort.
|
||||
*/
|
||||
export async function kimaiClockOut(opts: { timesheetId: number }): Promise<boolean> {
|
||||
const cfg = kimaiConfig();
|
||||
if (!cfg) {
|
||||
logger.debug('kimai not configured — clock-out skipped');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${cfg.baseUrl}/api/timesheets/${opts.timesheetId}/stop`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${cfg.token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn({ status: res.status, body: await safeText(res) }, 'kimai clock-out failed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'kimai clock-out error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { dayEntry, todo, blocker, user, type Todo } from '@cockpit/db';
|
||||
import { DayEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso, previousWerktag, nextWerktag } from '../lib/werktag.js';
|
||||
import { kimaiClockIn, kimaiClockOut, kimaiEnabled } from '../lib/kimai.js';
|
||||
|
||||
export const dayEntryRouter = router({
|
||||
/**
|
||||
@@ -162,9 +163,54 @@ export const dayEntryRouter = router({
|
||||
return { entry: created, todos: todoRows, carriedOver: stale.length };
|
||||
});
|
||||
|
||||
// Kimai-Sync (best-effort, nicht blockierend): Check-In = Clock-In.
|
||||
if (kimaiEnabled()) {
|
||||
const tsId = await kimaiClockIn({
|
||||
begin: result.entry.checkedInAt ?? new Date(),
|
||||
description: `ebw — ${ctx.user.name}`,
|
||||
});
|
||||
if (tsId !== null) {
|
||||
await ctx.db
|
||||
.update(dayEntry)
|
||||
.set({ kimaiTimesheetId: tsId, kimaiSynced: true })
|
||||
.where(eq(dayEntry.id, result.entry.id));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check-Out / Arbeitsende: markiert das Ende der Arbeits-Session. Best-effort
|
||||
* Kimai-Clock-Out, falls beim Check-In ein Timesheet gestartet wurde.
|
||||
*/
|
||||
checkOut: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const date = todayIso();
|
||||
const entry = (
|
||||
await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
|
||||
.limit(1)
|
||||
)[0];
|
||||
if (!entry) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist heute noch nicht locked in.' });
|
||||
}
|
||||
if (entry.checkedOutAt) return entry;
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await ctx.db
|
||||
.update(dayEntry)
|
||||
.set({ checkedOutAt: now, updatedAt: now })
|
||||
.where(eq(dayEntry.id, entry.id))
|
||||
.returning();
|
||||
|
||||
if (entry.kimaiTimesheetId !== null && kimaiEnabled()) {
|
||||
await kimaiClockOut({ timesheetId: entry.kimaiTimesheetId });
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
|
||||
/**
|
||||
* ToDos im Verlauf des Tages ändern (vor Reflexion).
|
||||
*/
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { X, Plus, RotateCw, BellRing, Play, Clock, Check } from 'lucide-react';
|
||||
import { X, Plus, RotateCw, BellRing, Play, Clock, Check, LogOut } from 'lucide-react';
|
||||
import { TimelineSection, ManualDialog } from '../components/time/TimelineSection';
|
||||
import { formatDurationCompact } from '../lib/format-duration';
|
||||
|
||||
/** Aktuelle Stunde in Europe/Berlin (0–23). */
|
||||
function berlinHourNow(): number {
|
||||
@@ -315,6 +316,61 @@ function AddTodoInline() {
|
||||
);
|
||||
}
|
||||
|
||||
function CheckInOutBar({
|
||||
checkedInAt,
|
||||
checkedOutAt,
|
||||
}: {
|
||||
checkedInAt: string | Date | null;
|
||||
checkedOutAt: string | Date | null;
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
const checkOut = trpc.dayEntry.checkOut.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast('Feierabend — Check-Out gespeichert.');
|
||||
await utils.dayEntry.today.invalidate();
|
||||
await utils.dashboard.overview.invalidate();
|
||||
},
|
||||
});
|
||||
const hhmm = (d: string | Date | null) =>
|
||||
d
|
||||
? new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(d))
|
||||
: '—';
|
||||
const duration =
|
||||
checkedInAt && checkedOutAt
|
||||
? formatDurationCompact(
|
||||
Math.max(0, Math.round((new Date(checkedOutAt).getTime() - new Date(checkedInAt).getTime()) / 1000)),
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-2 text-sm">
|
||||
<span>
|
||||
Arbeitsantritt <span className="font-mono">{hhmm(checkedInAt)}</span>
|
||||
</span>
|
||||
{checkedOutAt ? (
|
||||
<span className="text-[var(--color-ink-muted)]">
|
||||
· Feierabend <span className="font-mono">{hhmm(checkedOutAt)}</span>
|
||||
{duration && ` · ${duration}`}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="ml-auto"
|
||||
disabled={checkOut.isPending}
|
||||
onClick={() => checkOut.mutate()}
|
||||
>
|
||||
<LogOut size={14} /> Feierabend / Check-Out
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddUnplannedInline() {
|
||||
const utils = trpc.useUtils();
|
||||
const [title, setTitle] = useState('');
|
||||
@@ -357,7 +413,11 @@ function EinstempeltView({
|
||||
entry,
|
||||
}: {
|
||||
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped'; unplanned?: boolean }>;
|
||||
entry: { status: 'begun' | 'full' | 'absent' };
|
||||
entry: {
|
||||
status: 'begun' | 'full' | 'absent';
|
||||
checkedInAt: string | Date | null;
|
||||
checkedOutAt: string | Date | null;
|
||||
};
|
||||
}) {
|
||||
// Reflexion ist „fällig", wenn nach 18:00 Berlin und noch nicht abgeschlossen.
|
||||
const reflectionDue = entry.status !== 'full' && berlinHourNow() >= 18;
|
||||
@@ -375,6 +435,8 @@ function EinstempeltView({
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">{subtitle}</p>
|
||||
</header>
|
||||
|
||||
<CheckInOutBar checkedInAt={entry.checkedInAt} checkedOutAt={entry.checkedOutAt} />
|
||||
|
||||
{reflectionDue && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "day_entry" ADD COLUMN "checked_out_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "day_entry" ADD COLUMN "kimai_timesheet_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "day_entry" ADD COLUMN "kimai_synced" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,13 @@
|
||||
"when": 1780763141548,
|
||||
"tag": "0013_parallel_timers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1780764129593,
|
||||
"tag": "0014_checkout_kimai",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -219,6 +219,11 @@ export const dayEntry = pgTable(
|
||||
date: date('date').notNull(), // YYYY-MM-DD im Europe/Berlin
|
||||
status: text('status', { enum: ['begun', 'full', 'absent'] }).notNull(),
|
||||
checkedInAt: timestamp('checked_in_at', { withTimezone: true }),
|
||||
// Arbeitsende (Check-Out) — markiert das Ende der Arbeits-Session des Tages.
|
||||
checkedOutAt: timestamp('checked_out_at', { withTimezone: true }),
|
||||
// Kimai-Sync: ID des bei Check-In gestarteten Timesheets + Sync-Status.
|
||||
kimaiTimesheetId: integer('kimai_timesheet_id'),
|
||||
kimaiSynced: boolean('kimai_synced').notNull().default(false),
|
||||
reflectedAt: timestamp('reflected_at', { withTimezone: true }),
|
||||
reflectionText: text('reflection_text'),
|
||||
ampel: text('ampel', { enum: ['green', 'amber', 'red'] }),
|
||||
|
||||
Reference in new issue
Block a user