feat: ReactBits micro components integration

Installed 6 ReactBits micro components (src/components/):
- SwipeRow: swipe-to-action cards (lead approve/reject/snooze)
- CometDial: circular dial selector (future: dashboard KPI selector)
- CodeSlots: code input slots (future: API token confirmation)
- FuseButton: fuse-animated CTA with undo window
- SlideCommit: slide-to-confirm (timesheet week lock)
- HoldButton: hold-to-confirm (contract sign, dangerous actions)

Integrated into views:
- Timesheet: SlideCommit replaces 'Woche sperren' button
- Contracts: HoldButton replaces 'E-Signatur anfordern' (hold 1.5s to sign)
- Recurring: FuseButton replaces 'Jetzt ausführen' (with undo window)
- Leads: SwipeableLeadCard created (swipe to approve/reject/snooze)

Created wrapper components:
- swipeable-lead-card.tsx: SwipeRow wrapper for lead cards
- hold-to-confirm.tsx: HoldButton wrapper with loading state
- fuse-cta.tsx: FuseButton wrapper with toast feedback
- slide-to-lock.tsx: SlideCommit wrapper for GoBD week lock

0 TS errors, ESLint clean, no console errors.
This commit is contained in:
Z User committed 2026-10-28 02:36:53 +00:00
1 parent 21e112e5cb
commit 37e976a732
17 files changed
+2884 -25

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
'use client'
import FuseButton from '@/components/FuseButton'
import { useState } from 'react'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
// FuseButton wrapper for primary CTA actions with undo window.
// Used for: create deal, finalize invoice, run recurring, approve estimate.
export function FuseCTA({
label,
doneLabel = 'Erledigt',
undoLabel = 'Rückgängig',
onCommit,
onUndo,
disabled,
size = 'md',
}: {
label: string
doneLabel?: string
undoLabel?: string
onCommit: () => void | Promise<void>
onUndo?: () => void
disabled?: boolean
size?: 'sm' | 'md' | 'lg'
}) {
const [busy, setBusy] = useState(false)
const handleCommit = async () => {
setBusy(true)
const t = toast.loading('Wird ausgeführt…')
try {
await onCommit()
toast.success(doneLabel, { id: t })
} catch {
toast.error('Fehlgeschlagen', { id: t })
} finally {
setBusy(false)
}
}
return (
<FuseButton
label={busy ? 'Lädt…' : label}
doneLabel={doneLabel}
undoLabel={undoLabel}
onCommit={handleCommit}
onUndo={onUndo}
disabled={disabled || busy}
size={size}
commitOn="fuseEnd"
settle="stay"
fuse="bottom"
fuseThickness={2}
undoWindow={4000}
color="oklch(0.98 0 0)"
background="oklch(0.2 0 0)"
fuseColor="oklch(0.5 0 0)"
/>
)
}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import HoldButton from '@/components/HoldButton'
import { useState } from 'react'
import { toast } from 'sonner'
import { Loader2, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
// HoldButton wrapper for dangerous actions (PRD §5.6: confirm before destructive operations)
// Used for: contract sign, invoice finalize, reseed, module disable
export function HoldToConfirm({
children,
onConfirm,
holdTime = 1200,
size = 'md',
className,
disabled,
}: {
children: React.ReactNode
onConfirm: () => void | Promise<void>
holdTime?: number
size?: 'sm' | 'md' | 'lg'
className?: string
disabled?: boolean
}) {
const [holding, setHolding] = useState(false)
const handleHold = async () => {
setHolding(true)
try {
await onConfirm()
} catch {
// error handled by caller
} finally {
setHolding(false)
}
}
return (
<HoldButton
onHold={handleHold}
holdTime={holdTime}
size={size}
disabled={disabled || holding}
backgroundColor="oklch(0.97 0 0)"
fillColor="oklch(0.2 0 0)"
textColor="oklch(0.2 0 0)"
fillTextColor="oklch(0.98 0 0)"
radius={8}
className={cn(className)}
>
{holding ? (
<span className="flex items-center gap-1.5">
<Loader2 className="size-3.5 animate-spin" />
Ausführen…
</span>
) : (
children
)}
</HoldButton>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import SlideCommit from '@/components/SlideCommit'
import { useState } from 'react'
import { toast } from 'sonner'
import { Lock, Check, AlertTriangle } from 'lucide-react'
// SlideCommit wrapper for timesheet week lock (PRD §6.3: "lock after invoicing").
// User slides to confirm — prevents accidental week locking.
export function SlideToLock({
onConfirm,
disabled,
label = 'Schieben zum Sperren',
doneLabel = 'Woche gesperrt',
}: {
onConfirm: () => void | Promise<void>
disabled?: boolean
label?: string
doneLabel?: string
}) {
const [phase, setPhase] = useState<'idle' | 'pending' | 'done'>('idle')
const handleConfirm = async () => {
setPhase('pending')
try {
await onConfirm()
setPhase('done')
} catch {
setPhase('idle')
toast.error('Sperren fehlgeschlagen')
}
}
return (
<div className="w-full max-w-sm">
<SlideCommit
label={label}
doneLabel={doneLabel}
onConfirm={handleConfirm}
onDone={() => {}}
onError={() => setPhase('idle')}
disabled={disabled || phase === 'done' || phase === 'pending'}
trackColor="oklch(0.95 0 0)"
handleColor="oklch(0.2 0 0)"
successColor="oklch(0.3 0 0)"
dangerColor="oklch(0.5 0 0)"
width={320}
height={48}
radius={10}
holdMs={400}
icon={phase === 'done' ? <Check className="size-5" /> : <Lock className="size-4" />}
/>
</div>
)
}
@@ -0,0 +1,77 @@
'use client'
import SwipeRow from '@/components/SwipeRow'
import type { SwipeAction } from '@/components/SwipeRow'
import { Check, X, Clock, MapPin } from 'lucide-react'
import { formatRelative } from '@/lib/format'
const SCORE_LABELS: Record<string, string> = {
'new-site': 'Neue Website', redesign: 'Redesign', seo: 'SEO', aeo: 'AEO/GEO',
shop: 'Shop', maintenance: 'Wartung', accessibility: 'Barrierefreiheit',
}
export interface SwipeableLeadData {
id: string
companyName: string
website: string | null
city: string | null
score: number
scoreLabel: string | null
createdAt: string
}
export function SwipeableLeadCard({
lead,
onApprove,
onReject,
onSnooze,
}: {
lead: SwipeableLeadData
onApprove: (id: string) => void
onReject: (id: string) => void
onSnooze: (id: string) => void
}) {
const actions: SwipeAction[] = [
{ id: 'approve', label: 'Genehmigen', icon: <Check className="size-5" />, color: 'oklch(0.45 0 0)', dismiss: false },
{ id: 'snooze', label: 'Snooze', icon: <Clock className="size-5" />, color: 'oklch(0.6 0 0)', dismiss: false },
{ id: 'reject', label: 'Ablehnen', icon: <X className="size-5" />, color: 'oklch(0.5 0 0)', dismiss: true },
]
const handleAction = (action: SwipeAction) => {
if (action.id === 'approve') onApprove(lead.id)
else if (action.id === 'snooze') onSnooze(lead.id)
else if (action.id === 'reject') onReject(lead.id)
}
return (
<SwipeRow
actions={actions}
actionWidth={72}
height={88}
fullSwipe={true}
commitAt={0.55}
rowColor="oklch(1 0 0)"
actionColor="oklch(0.97 0 0)"
onAction={handleAction}
>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex size-12 shrink-0 flex-col items-center justify-center rounded-lg bg-foreground text-background">
<span className="text-lg font-bold leading-none">{lead.score}</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{lead.companyName}</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{lead.website && <span className="truncate">{lead.website}</span>}
{lead.city && <><span>·</span><span className="flex items-center gap-0.5"><MapPin className="size-2.5" />{lead.city}</span></>}
</div>
{lead.scoreLabel && SCORE_LABELS[lead.scoreLabel] && (
<span className="mt-0.5 inline-block rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium">
{SCORE_LABELS[lead.scoreLabel]}
</span>
)}
</div>
<span className="shrink-0 text-[10px] text-muted-foreground">{formatRelative(lead.createdAt)}</span>
</div>
</SwipeRow>
)
}
+12 -4
View File
@@ -28,6 +28,7 @@ import {
} from '@/components/ui/dialog'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, formatDateTime, daysUntil } from '@/lib/format'
import { HoldToConfirm } from '../hold-to-confirm'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -312,10 +313,17 @@ export function ContractsView() {
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setSelected(null)}>Schließen</Button>
{canSign && (
<Button onClick={() => sign(selected)} disabled={signing} className="gap-1.5">
{signing ? <Loader2 className="size-3.5 animate-spin" /> : <PenLine className="size-3.5" />}
E-Signatur anfordern
</Button>
<HoldToConfirm
onConfirm={() => sign(selected)}
holdTime={1500}
size="md"
disabled={signing}
>
<span className="flex items-center gap-1.5">
<PenLine className="size-3.5" />
E-Signatur anfordern
</span>
</HoldToConfirm>
)}
</DialogFooter>
</>
+8 -4
View File
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { FuseCTA } from '../fuse-cta'
import { formatEuro, formatEuroCompact, formatDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -76,10 +77,13 @@ export function RecurringView() {
title="Wiederkehrende Rechnungen"
description="Automatische Generierung von wiederkehrenden Rechnungen (Wartung/SLA-Verträge). Bei Mahnstufe 3: automatische Lizenz-Suspendierung (PRD §6.4)."
actions={
<Button size="sm" onClick={runNow} disabled={running} className="gap-1.5">
{running ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
Jetzt ausführen
</Button>
<FuseCTA
label="Jetzt ausführen"
doneLabel="Ausgeführt"
onCommit={runNow}
disabled={running}
size="sm"
/>
}
/>
+2 -4
View File
@@ -11,6 +11,7 @@ import { formatMinutes } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { ChevronLeft, ChevronRight, Lock, Clock, TrendingUp, CheckCircle2, Calendar } from 'lucide-react'
import { SlideToLock } from '../slide-to-lock'
interface DayData {
date: string
@@ -83,10 +84,7 @@ export function TimesheetView() {
description="Wochen-basierte Zeiterfassung mit PM-Freigabe (Lock). Nach Freigabe können Einträge nicht mehr geändert werden — GoBD-konform."
actions={
!stats.allLocked && stats.totalEntries > 0 ? (
<Button size="sm" onClick={lockWeek} disabled={locking} className="gap-1.5">
<Lock className="size-4" />
Woche sperren
</Button>
<SlideToLock onConfirm={lockWeek} disabled={locking} />
) : stats.allLocked ? (
<Badge variant="outline" className="gap-1">
<CheckCircle2 className="size-3" />Gesperrt