feat(zeit): Check-In/Check-Out + Kimai-Sync-Naht (ENTWICKLUN-9)
ci / quality (push) Failing after 42s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 1m20s

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:
dejanandClaude Opus 4.8 committed 2026-06-06 18:46:18 +02:00
1 parent 9942cd8e88
commit 5bbb4abffa
9 files changed
+3542 -2

No files matched your search

+64 -2
View File
@@ -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"