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:
1 parent
21e112e5cb
commit
37e976a732
17 files changed
+2884
-25
No files matched your search
@@ -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)"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user