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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user