Merge remote-tracking branch 'origin/main'

# Conflicts:
#	db/custom.db
#	src/components/FuseButton.tsx
#	src/components/SlideCommit.tsx
This commit is contained in:
Z User committed 2026-10-28 17:41:22 +00:00
commit 58521ca03a
23 files changed
+2622 -26

No files matched your search

+23
View File
@@ -1,5 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useAos } from '@/store/aos'
import { SidebarNav } from './sidebar-nav'
import { TopBar } from './top-bar'
@@ -38,10 +39,32 @@ import { MobileBottomNav } from './mobile-bottom-nav'
export function AppShell() {
const { view, sidebarOpen, setSidebarOpen, portalMode } = useAos()
const [mounted, setMounted] = useState(false)
// Hydration gate: wait for client mount before rendering interactive UI
// This prevents Radix UI useId() mismatches between server and client
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true)
}, [])
// Portal mode renders a completely separate experience
if (portalMode) return <PortalShell />
// Before mount: render a minimal skeleton to prevent hydration mismatch
if (!mounted) {
return (
<div className="flex min-h-screen w-full bg-background">
<aside className="hidden w-64 shrink-0 border-r border-border md:block" />
<div className="flex min-w-0 flex-1 flex-col">
<header className="h-16 border-b border-border" />
<main className="flex-1" />
<Footer />
</div>
</div>
)
}
return (
<div className="flex min-h-screen w-full bg-background">
{/* Desktop sidebar */}
+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>
</>
+23
View File
@@ -17,6 +17,7 @@ import {
Filter,
ArrowRight,
} from 'lucide-react'
import { SwipeableLeadCard } from '../swipeable-lead-card'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -207,6 +208,28 @@ export function LeadsView() {
<div className="space-y-1.5 p-2">
{filtered.map((l) => {
const meta = STATUS_META[l.status] ?? STATUS_META.pending
// Pending leads get SwipeRow (swipe to approve/reject/snooze)
if (l.status === 'pending') {
return (
<div key={l.id} className={cn('rounded-md border overflow-hidden', selected?.id === l.id && 'ring-1 ring-foreground/40')}>
<SwipeableLeadCard
lead={{
id: l.id,
companyName: l.companyName,
website: l.website,
city: l.city,
score: l.score,
scoreLabel: l.scoreLabel,
createdAt: l.createdAt,
}}
onApprove={(id) => { setSelectedId(id); review(id, 'approved') }}
onReject={(id) => { setSelectedId(id); review(id, 'rejected', 'Nicht zur Zielgruppe') }}
onSnooze={(id) => { setSelectedId(id); review(id, 'snoozed', 'Später erneut prüfen') }}
/>
</div>
)
}
// Non-pending leads keep the simple button card
return (
<button
key={l.id}
+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