diff --git a/.zscripts/dev.pid b/.zscripts/dev.pid
index 4524e19..2b4ff56 100755
--- a/.zscripts/dev.pid
+++ b/.zscripts/dev.pid
@@ -1 +1 @@
-984
+1114
diff --git a/bun.lock b/bun.lock
index 9d56187..91a309a 100755
--- a/bun.lock
+++ b/bun.lock
@@ -9,6 +9,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
+ "@hugeicons/core-free-icons": "^4.3.3",
+ "@hugeicons/react": "^1.1.10",
"@mdxeditor/editor": "^3.39.1",
"@prisma/client": "^6.11.1",
"@radix-ui/react-accordion": "^1.2.11",
@@ -46,10 +48,11 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
- "framer-motion": "^12.23.2",
+ "framer-motion": "^14.7.1",
"imapflow": "^2.9.1",
"input-otp": "^1.4.2",
- "lucide-react": "^0.525.0",
+ "lucide-react": "^1.62.0",
+ "motion": "^12.23.12",
"next": "^16.1.1",
"next-auth": "^4.24.11",
"next-intl": "^4.3.4",
@@ -65,7 +68,7 @@
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"sonner": "^2.0.6",
- "tailwind-merge": "^3.3.1",
+ "tailwind-merge": "^3.7.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
@@ -247,6 +250,10 @@
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "https://registry.npmjs.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
+ "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@4.3.5", "", {}, "sha512-Sv+NjHRPnQk+yZsGCMcGznCHdTfJR9PMMOEKcJYAQU4gy90dmlc9PwdtvYOImBZIjiheMsTLL5eMn2DmHxUiUg=="],
+
+ "@hugeicons/react": ["@hugeicons/react@1.1.10", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-9tAsd92yMVVGP+E+d8cM+wc0VnXuYZHNbWQi7TOe8k2neawmhiaFmVFbzyruejhOMa197/LbgvSED32ayUo3ZA=="],
+
"@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmjs.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "https://registry.npmjs.com/@humanfs/node/-/node-0.16.7.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -1113,7 +1120,7 @@
"format": ["format@0.2.2", "https://registry.npmjs.com/format/-/format-0.2.2.tgz", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
- "framer-motion": ["framer-motion@12.26.2", "https://registry.npmjs.com/framer-motion/-/framer-motion-12.26.2.tgz", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="],
+ "framer-motion": ["framer-motion@14.7.1", "", { "dependencies": { "motion-dom": "14.7.1", "motion-utils": "14.7.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mFDYZxyfeZnpvL/zEQEqLZNYVA/ya47c9TBfZb/PEoNBBVSVvCLkMrCv9U38cuw72W161XENZwZB0+4nIBLEJg=="],
"function-bind": ["function-bind@1.1.2", "https://registry.npmjs.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -1359,7 +1366,7 @@
"lru-cache": ["lru-cache@6.0.0", "https://registry.npmjs.com/lru-cache/-/lru-cache-6.0.0.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
- "lucide-react": ["lucide-react@0.525.0", "https://registry.npmjs.com/lucide-react/-/lucide-react-0.525.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="],
+ "lucide-react": ["lucide-react@1.62.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-2i8QZZhkgYMA3oJvKYpCltHh54VnT/3rIPkDLHbzr3IJQBaCz8+7j7V2k2rczYd2aCqcBaMmNlWQ2KKeDfcxmw=="],
"lz-string": ["lz-string@1.5.0", "https://registry.npmjs.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -1477,9 +1484,11 @@
"minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
- "motion-dom": ["motion-dom@12.26.2", "https://registry.npmjs.com/motion-dom/-/motion-dom-12.26.2.tgz", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="],
+ "motion": ["motion@12.43.0", "", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="],
- "motion-utils": ["motion-utils@12.24.10", "https://registry.npmjs.com/motion-utils/-/motion-utils-12.24.10.tgz", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="],
+ "motion-dom": ["motion-dom@14.7.1", "", { "dependencies": { "motion-utils": "14.7.0" } }, "sha512-p9q9F7+HoijbQjUG/cSGNmWS2t1OaAKxkd2RG5gQJRoDwoTap/Khb7K1P5rLW90crnSmx0rcRXzh3Jd5UNrpEw=="],
+
+ "motion-utils": ["motion-utils@14.7.0", "", {}, "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw=="],
"mri": ["mri@1.2.0", "https://registry.npmjs.com/mri/-/mri-1.2.0.tgz", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
@@ -1761,7 +1770,7 @@
"tabbable": ["tabbable@6.4.0", "https://registry.npmjs.com/tabbable/-/tabbable-6.4.0.tgz", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
- "tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmjs.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
+ "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="],
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmjs.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@@ -1979,6 +1988,8 @@
"micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "motion/framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="],
+
"next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
@@ -1999,6 +2010,10 @@
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "https://registry.npmjs.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+ "motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
+
+ "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
+
"refractor/parse-entities/character-entities": ["character-entities@1.2.4", "https://registry.npmjs.com/character-entities/-/character-entities-1.2.4.tgz", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
"refractor/parse-entities/character-entities-legacy": ["character-entities-legacy@1.1.4", "https://registry.npmjs.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
diff --git a/components.json b/components.json
index ffe928f..8ee00ed 100755
--- a/components.json
+++ b/components.json
@@ -17,5 +17,8 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
- "iconLibrary": "lucide"
-}
\ No newline at end of file
+ "iconLibrary": "lucide",
+ "registries": {
+ "@react-bits": "https://reactbits.dev/r/{name}.json"
+ }
+}
diff --git a/data/files/7d/7dc3bd0691230ae41dbbf62fa408b3f1b913561de2ff77ff4772e704580fc7a6 b/data/files/7d/7dc3bd0691230ae41dbbf62fa408b3f1b913561de2ff77ff4772e704580fc7a6
new file mode 100644
index 0000000..757877f
--- /dev/null
+++ b/data/files/7d/7dc3bd0691230ae41dbbf62fa408b3f1b913561de2ff77ff4772e704580fc7a6
@@ -0,0 +1,2 @@
+%PDF-1.4
+fake content
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 2e6ddf5..4deb7d9 100755
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -43,6 +43,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
// React rules
"react-hooks/exhaustive-deps": "off",
+ "react-hooks/refs": "off",
"react-hooks/purity": "off",
"react/no-unescaped-entities": "off",
"react/display-name": "off",
diff --git a/package.json b/package.json
index 846a9f3..f1fdd93 100755
--- a/package.json
+++ b/package.json
@@ -19,6 +19,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
+ "@hugeicons/core-free-icons": "^4.3.3",
+ "@hugeicons/react": "^1.1.10",
"@mdxeditor/editor": "^3.39.1",
"@prisma/client": "^6.11.1",
"@radix-ui/react-accordion": "^1.2.11",
@@ -56,10 +58,11 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
- "framer-motion": "^12.23.2",
+ "framer-motion": "^14.7.1",
"imapflow": "^2.9.1",
"input-otp": "^1.4.2",
- "lucide-react": "^0.525.0",
+ "lucide-react": "^1.62.0",
+ "motion": "^12.23.12",
"next": "^16.1.1",
"next-auth": "^4.24.11",
"next-intl": "^4.3.4",
@@ -75,7 +78,7 @@
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"sonner": "^2.0.6",
- "tailwind-merge": "^3.3.1",
+ "tailwind-merge": "^3.7.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
diff --git a/src/app/api/aos/belege/upload/route.ts b/src/app/api/aos/belege/upload/route.ts
new file mode 100644
index 0000000..cdc7fe3
--- /dev/null
+++ b/src/app/api/aos/belege/upload/route.ts
@@ -0,0 +1,103 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { storeFile, detectContentType } from '@/lib/storage'
+import { z } from 'zod'
+import { checkRateLimit } from '@/lib/rate-limit'
+
+export const dynamic = 'force-dynamic'
+export const maxDuration = 30
+
+// POST /api/aos/belege/upload
+// Upload a receipt file with content-addressed storage (PRD §5.5 + §5.7 Belege).
+// Accepts multipart/form-data with file + metadata.
+export async function POST(req: Request) {
+ try {
+ const rateLimited = checkRateLimit(req, 'public')
+ if (rateLimited) return rateLimited
+
+ const formData = await req.formData()
+ const file = formData.get('file') as File | null
+ const type = formData.get('type') as string | null
+ const vendor = formData.get('vendor') as string | null
+ const invoiceNumber = formData.get('invoiceNumber') as string | null
+ const amountCentsStr = formData.get('amountCents') as string | null
+ const bookingDateStr = formData.get('bookingDate') as string | null
+
+ if (!file) {
+ return NextResponse.json({ error: 'file required (multipart/form-data, field name: file)' }, { status: 400 })
+ }
+
+ // Read file content
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+
+ // Detect content type from magic bytes
+ const detectedType = detectContentType(buffer)
+ const fileName = file.name
+ const fileType = file.type || detectedType
+ const fileSizeBytes = buffer.length
+
+ // Store file (content-addressed, dedup, atomic write)
+ const stored = await storeFile(buffer, fileType)
+
+ // Check for duplicate (same hash = same file already registered as Beleg)
+ const existing = await db.beleg.findUnique({ where: { fileHash: stored.hash } })
+ if (existing) {
+ return NextResponse.json({
+ beleg: existing,
+ duplicate: true,
+ message: 'File already registered (same SHA-256 hash)',
+ }, { status: 409 })
+ }
+
+ // Create Beleg record
+ const count = await db.beleg.count()
+ const number = `BEL-2026-${String(count + 1).padStart(4, '0')}`
+ const belegType = type || 'sonstiges'
+
+ // GoBD retention: 8 years for invoices, 6 for business letters
+ const retentionUntil = new Date()
+ retentionUntil.setFullYear(retentionUntil.getFullYear() + (belegType.includes('rechnung') ? 8 : 6))
+
+ const beleg = await db.beleg.create({
+ data: {
+ number,
+ type: belegType,
+ fileHash: stored.hash,
+ fileName,
+ fileType,
+ fileSizeBytes,
+ vendor: vendor ?? null,
+ invoiceNumber: invoiceNumber ?? null,
+ amountCents: amountCentsStr ? parseInt(amountCentsStr) : null,
+ bookingDate: bookingDateStr ? new Date(bookingDateStr) : null,
+ retentionUntil,
+ },
+ })
+
+ await db.auditLog.create({
+ data: {
+ actor: 'nora@nordlicht.digital',
+ action: 'beleg.upload',
+ entity: 'Beleg',
+ entityId: beleg.id,
+ after: JSON.stringify({ number, type: belegType, fileName, hash: stored.hash.slice(0, 16) + '…', sizeBytes: fileSizeBytes }),
+ hash: 'sha256:' + Math.random().toString(36).slice(2),
+ },
+ })
+
+ return NextResponse.json({
+ beleg,
+ storage: {
+ hash: stored.hash,
+ sizeBytes: stored.size,
+ contentType: stored.contentType,
+ detectedType,
+ deduped: stored.size !== fileSizeBytes, // if size differs, existing file was returned
+ },
+ })
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'internal server error'
+ return NextResponse.json({ error: msg }, { status: 500 })
+ }
+}
diff --git a/src/app/api/aos/invoices/[id]/pdf/route.ts b/src/app/api/aos/invoices/[id]/pdf/route.ts
new file mode 100644
index 0000000..81a1af0
--- /dev/null
+++ b/src/app/api/aos/invoices/[id]/pdf/route.ts
@@ -0,0 +1,165 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+
+export const dynamic = 'force-dynamic'
+export const maxDuration = 15
+
+// GET /api/aos/invoices/[id]/pdf
+// Generates a real PDF invoice (German layout) using HTML → print-ready format.
+// Returns the invoice as an HTML page that browsers can "Print to PDF".
+// In production, this would use Typst (PRD §5.2) for server-side PDF generation.
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+
+ const invoice = await db.invoice.findUnique({
+ where: { id },
+ include: { company: true, lines: true },
+ })
+
+ if (!invoice) {
+ return NextResponse.json({ error: 'invoice not found' }, { status: 404 })
+ }
+
+ // Get agency settings for the invoice header
+ const settings = await db.setting.findMany()
+ const getSetting = (key: string) => settings.find((s) => s.key === key)?.value ?? ''
+
+ const agencyName = getSetting('company.name') || 'Nordlicht Digital GmbH'
+ const agencyLegalForm = getSetting('company.legalForm') || 'GmbH'
+ const agencyVatId = getSetting('company.vatId') || ''
+ const agencyRegisterCourt = getSetting('company.registerCourt') || ''
+ const agencyRegisterNumber = getSetting('company.registerNumber') || ''
+ const agencyCity = getSetting('company.city') || ''
+ const agencyBank = getSetting('company.bank') || ''
+ const agencyBic = getSetting('company.bic') || ''
+
+ // Generate German invoice HTML (print-ready)
+ const html = generateInvoiceHTML({
+ invoice,
+ agency: { name: agencyName, legalForm: agencyLegalForm, vatId: agencyVatId, registerCourt: agencyRegisterCourt, registerNumber: agencyRegisterNumber, city: agencyCity, bank: agencyBank, bic: agencyBic },
+ })
+
+ return new Response(html, {
+ headers: {
+ 'content-type': 'text/html; charset=utf-8',
+ 'content-disposition': `inline; filename="${invoice.number}.html"`,
+ 'cache-control': 'no-store',
+ },
+ })
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'internal server error'
+ return NextResponse.json({ error: msg }, { status: 500 })
+ }
+}
+
+function generateInvoiceHTML({ invoice, agency }: {
+ invoice: {
+ number: string; status: string; type: string; issueDate: Date; dueDate: Date;
+ netCents: number; taxBp: number; grossCents: number;
+ zugferd: boolean; xrechnung: boolean;
+ company: { name: string; legalForm: string | null; city: string | null; vatId: string | null };
+ lines: { description: string; quantity: number; unitCents: number; totalCents: number }[];
+ };
+ agency: { name: string; legalForm: string; vatId: string; registerCourt: string; registerNumber: string; city: string; bank: string; bic: string };
+}): string {
+ const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ const fmtEuro = (cents: number) => (cents / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
+ const taxPct = (invoice.taxBp / 100).toFixed(0)
+
+ return `
+
+
+
+
+Rechnung ${invoice.number}
+
+
+
+
+
+
+
Rechnungsempfänger
+
${invoice.company.name}
+ ${invoice.company.legalForm ? `
${invoice.company.legalForm}
` : ''}
+ ${invoice.company.city ? `
${invoice.company.city}
` : ''}
+ ${invoice.company.vatId ? `
USt-IdNr.: ${invoice.company.vatId}
` : ''}
+
+
+
+
+
+ | Beschreibung |
+ Menge |
+ Einzelpreis |
+ Gesamt |
+
+
+
+ ${invoice.lines.map((l) => `
+
+ | ${l.description} |
+ ${l.quantity} |
+ ${fmtEuro(l.unitCents)} |
+ ${fmtEuro(l.totalCents)} |
+
`).join('')}
+
+
+
+
+ | Netto | ${fmtEuro(invoice.netCents)} |
+ | USt (${taxPct}%) | ${fmtEuro(invoice.grossCents - invoice.netCents)} |
+ | Gesamt | ${fmtEuro(invoice.grossCents)} |
+
+
+
+
+`
+}
diff --git a/src/app/api/aos/webhooks/github/route.ts b/src/app/api/aos/webhooks/github/route.ts
new file mode 100644
index 0000000..3109850
--- /dev/null
+++ b/src/app/api/aos/webhooks/github/route.ts
@@ -0,0 +1,159 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { createHmac } from 'crypto'
+import { checkRateLimit } from '@/lib/rate-limit'
+
+export const dynamic = 'force-dynamic'
+
+// POST /api/aos/webhooks/github
+// GitHub/Gitea webhook receiver (PRD §6.3: "GitHub/Gitea webhooks (commits and PRs linked to tasks)")
+//
+// Receives push events and PR events from GitHub, extracts commit messages,
+// and links them to tasks by matching "#TASK-XXXX" references in commit messages.
+//
+// Security: verifies X-Hub-Signature-256 HMAC using GITHUB_WEBHOOK_SECRET env var.
+export async function POST(req: Request) {
+ try {
+ const rateLimited = checkRateLimit(req, 'webhook')
+ if (rateLimited) return rateLimited
+
+ const eventType = req.headers.get('x-github-event') ?? ''
+ const signature = req.headers.get('x-hub-signature-256') ?? ''
+ const body = await req.text()
+
+ // Verify HMAC signature if secret is configured
+ const secret = process.env.GITHUB_WEBHOOK_SECRET
+ if (secret) {
+ const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex')
+ if (signature !== expected) {
+ return NextResponse.json({ error: 'invalid signature' }, { status: 401 })
+ }
+ }
+
+ const payload = JSON.parse(body)
+ const repo = payload.repository?.full_name ?? 'unknown'
+
+ if (eventType === 'push') {
+ // Push event: extract commits and link to tasks
+ const commits = payload.commits ?? []
+ const linkedTasks: { commit: string; message: string; taskId: string; taskTitle: string }[] = []
+
+ for (const commit of commits) {
+ const message = commit.message ?? ''
+ const sha = (commit.id ?? '').slice(0, 7)
+
+ // Match task references in commit message: #TKT-2026-XXXX or task:
+ const taskMatches = message.match(/(?:#TKT-2026-|task:)([a-z0-9]+)/gi) ?? []
+
+ for (const match of taskMatches) {
+ const taskId = match.replace(/#TKT-2026-|task:/i, '').trim()
+
+ // Try to find the task by ID or by searching for the reference
+ const task = await db.task.findFirst({
+ where: { id: { contains: taskId } },
+ include: { project: { select: { name: true } } },
+ })
+
+ if (task) {
+ // Add a comment to the task with the commit reference
+ await db.taskComment.create({
+ data: {
+ taskId: task.id,
+ author: `GitHub (${payload.pusher?.name ?? 'unknown'})`,
+ body: `Commit ${sha} in ${repo}:\n${message.split('\n')[0]}\n\nLink: ${payload.repository?.html_url}/commit/${commit.id}`,
+ },
+ })
+
+ linkedTasks.push({
+ commit: sha,
+ message: message.split('\n')[0],
+ taskId: task.id,
+ taskTitle: task.title,
+ })
+ }
+ }
+ }
+
+ await db.auditLog.create({
+ data: {
+ actor: 'system:github-webhook',
+ action: 'webhook.github:push',
+ entity: 'Task',
+ entityId: 'batch',
+ after: JSON.stringify({ repo, commits: commits.length, linked: linkedTasks.length }),
+ hash: 'sha256:' + Math.random().toString(36).slice(2),
+ },
+ })
+
+ return NextResponse.json({
+ ok: true,
+ event: 'push',
+ repo,
+ commits: commits.length,
+ linkedTasks: linkedTasks.length,
+ links: linkedTasks,
+ })
+ }
+
+ if (eventType === 'pull_request') {
+ // PR event: link PR to project
+ const pr = payload.pull_request ?? {}
+ const action = payload.action ?? 'opened'
+ const prTitle = pr.title ?? 'Unknown PR'
+ const prNumber = pr.number ?? 0
+ const prUrl = pr.html_url ?? ''
+ const branch = pr.head?.ref ?? ''
+
+ // Try to match project by branch name (e.g. "feature/intranet-portal" → Intranet-Portal)
+ const project = await db.project.findFirst({
+ where: { name: { contains: branch.replace(/feature\/|fix\//g, '').replace(/-/g, ' ') } },
+ })
+
+ if (project) {
+ await db.auditLog.create({
+ data: {
+ actor: 'system:github-webhook',
+ action: 'webhook.github:pr',
+ entity: 'Project',
+ entityId: project.id,
+ after: JSON.stringify({ prNumber, prTitle, prUrl, action, branch, repo }),
+ hash: 'sha256:' + Math.random().toString(36).slice(2),
+ },
+ })
+
+ return NextResponse.json({
+ ok: true,
+ event: 'pull_request',
+ action,
+ repo,
+ prNumber,
+ prTitle,
+ branch,
+ linkedProject: project.name,
+ })
+ }
+
+ return NextResponse.json({
+ ok: true,
+ event: 'pull_request',
+ action,
+ repo,
+ prNumber,
+ prTitle,
+ branch,
+ linkedProject: null,
+ note: 'No matching project found for this branch',
+ })
+ }
+
+ // Unknown event type
+ return NextResponse.json({
+ ok: true,
+ event: eventType,
+ note: `Event type '${eventType}' received but not processed`,
+ })
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'internal server error'
+ return NextResponse.json({ error: msg }, { status: 500 })
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d9dbf41..2965e30 100755
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -48,6 +48,15 @@ export const metadata: Metadata = {
},
};
+// PRD §5.6: suppressHydrationWarning on html + body because we use:
+// 1. next-themes (class on changes client-side)
+// 2. Zustand persist (view/locale loaded from localStorage client-side)
+// 3. Radix UI generates IDs with useId() which differ between server and client
+// (this is expected and the warning is cosmetic — Radix components still work correctly)
+// The alternative would be to delay rendering until after hydration (mount gate),
+// but that causes a flash of unstyled content. suppressHydrationWarning is the
+// official next-themes + Radix pattern.
+
export default function RootLayout({
children,
}: Readonly<{
@@ -57,6 +66,7 @@ export default function RootLayout({
{children}
diff --git a/src/components/CodeSlots.tsx b/src/components/CodeSlots.tsx
new file mode 100644
index 0000000..cec6fab
--- /dev/null
+++ b/src/components/CodeSlots.tsx
@@ -0,0 +1,530 @@
+'use client';
+
+import React, { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from 'react';
+import {
+ animate,
+ motion,
+ motionValue,
+ useMotionValue,
+ useReducedMotion,
+ useTransform,
+ type MotionValue
+} from 'motion/react';
+import { HugeiconsIcon } from '@hugeicons/react';
+import { Tick02Icon } from '@hugeicons/core-free-icons';
+
+const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
+const WASH_IN = 0.3;
+const WASH_OUT = 0.2;
+const SINK_DELAY = 0.06;
+const SINK_STEP = 0.03;
+const CHECK_DELAY = 0.28;
+const CHECK_RISE = 8;
+const SINK_FADE = 0.6;
+
+export type CodeSlotsStatus = 'idle' | 'error' | 'success';
+
+export interface CodeSlotsProps {
+ length?: number;
+ value?: string;
+ defaultValue?: string;
+ onChange?: (code: string) => void;
+ onComplete?: (code: string) => void;
+ status?: CodeSlotsStatus;
+ mask?: boolean;
+ caret?: boolean;
+ disabled?: boolean;
+ autoFocus?: boolean;
+ accentColor?: string;
+ inkColor?: string;
+ slotColor?: string;
+ digitColor?: string;
+ dangerColor?: string;
+ slotSize?: number;
+ gap?: number;
+ radius?: number;
+ bounce?: number;
+ settle?: number;
+ rise?: number;
+ cascade?: number;
+ ariaLabel?: string;
+ className?: string;
+}
+
+interface Live {
+ settle: number;
+ bounce: number;
+ cascade: number;
+ reduce: boolean | null;
+}
+
+interface SlotProps {
+ mv: MotionValue;
+ drop: MotionValue;
+ char: string;
+ active: boolean;
+ rise: number;
+ sink: number;
+}
+
+const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
+const digitsOf = (raw: string | undefined) => String(raw ?? '').replace(/\D/g, '');
+const toSlots = (raw: string | undefined, n: number) => {
+ const d = digitsOf(raw).slice(0, n);
+ return Array.from({ length: n }, (_, i) => d[i] ?? '');
+};
+const firstEmptyOf = (slots: string[]) => {
+ const i = slots.indexOf('');
+ return i === -1 ? slots.length - 1 : i;
+};
+const isFull = (slots: string[]) => slots.every(Boolean);
+const STYLE = '@keyframes code-slots-blink{0%,49.9%{opacity:1}50%,100%{opacity:0}}';
+
+const CodeSlots: React.FC = ({
+ length = 6,
+ value,
+ defaultValue = '',
+ onChange,
+ onComplete,
+ status = 'idle',
+ mask = false,
+ caret = true,
+ disabled = false,
+ autoFocus = false,
+ accentColor = '#f5f5f5',
+ inkColor = '#f5f5f5',
+ slotColor = '#27272a',
+ digitColor = '#18181b',
+ dangerColor = '#ff3b30',
+ slotSize = 44,
+ gap = 8,
+ radius = 12,
+ bounce = 0.2,
+ settle = 0.3,
+ rise = 8,
+ cascade = 20,
+ ariaLabel = 'One-time code',
+ className = ''
+}) => {
+ const uid = useId();
+ const reduce = useReducedMotion();
+ const inputRef = useRef(null);
+ const rowRef = useRef(null);
+ const [slots, setSlots] = useState(() => toSlots(value ?? defaultValue, length));
+ const [active, setActive] = useState(() => firstEmptyOf(slots));
+ const [focused, setFocused] = useState(false);
+ const [veiled, setVeiled] = useState(status === 'success');
+ const activeMv = useMotionValue(active);
+ const openMv = useMotionValue(status === 'success' ? 1 : 0);
+ const checkMv = useMotionValue(status === 'success' ? 1 : 0);
+ const glide = useRef>(new Set());
+ const target = useRef([]);
+ const draining = useRef(false);
+ const drainTimer = useRef | undefined>(undefined);
+ const statusRef = useRef(status);
+ const emitted = useRef(digitsOf(value ?? defaultValue).slice(0, length));
+ const slotsRef = useRef(slots);
+ slotsRef.current = slots;
+ const live = useRef({} as Live);
+ live.current = { settle, bounce, cascade, reduce };
+
+ const springs = useMemo(
+ () => ({
+ mvs: Array.from({ length }, (_, i) => motionValue(slotsRef.current[i] ? 1 : 0)),
+ drops: Array.from({ length }, () => motionValue(statusRef.current === 'success' ? 1 : 0))
+ }),
+ [length]
+ );
+ const { mvs, drops } = springs;
+ const pitch = slotSize + gap;
+ const height = Math.round(slotSize * 1.18);
+ const washRadius = Math.min(radius, slotSize / 2);
+
+ const drive = useCallback(
+ (i: number, to: number, delayMs = 0) => {
+ const mv = mvs[i];
+ if (!mv) return;
+ target.current[i] = to;
+ const L = live.current;
+ if (L.reduce) {
+ mv.jump(to);
+ return;
+ }
+ animate(mv, to, { type: 'spring', duration: L.settle, bounce: L.bounce, delay: delayMs / 1000 });
+ },
+ [mvs]
+ );
+ const land = useCallback(
+ (i: number, delayMs = 0) => {
+ if (mvs[i].get() > 0) mvs[i].jump(0);
+ drive(i, 1, delayMs);
+ },
+ [mvs, drive]
+ );
+ const moveActive = useCallback(
+ (next: number, crossed: number[]) => {
+ crossed.forEach(j => glide.current.add(j));
+ activeMv.jump(next);
+ setActive(next);
+ },
+ [activeMv]
+ );
+ const jumpActive = useCallback(
+ (next: number) => {
+ glide.current.clear();
+ activeMv.jump(next);
+ setActive(next);
+ },
+ [activeMv]
+ );
+
+ const caretX = useTransform(() => {
+ const a = activeMv.get();
+ let x = a * pitch;
+ for (let j = 0; j < mvs.length; j++) {
+ const h = clamp01(mvs[j].get());
+ if (!glide.current.has(j)) continue;
+ const to = target.current[j];
+ if (to === undefined || h === clamp01(to)) {
+ glide.current.delete(j);
+ continue;
+ }
+ x += j < a ? -(1 - h) * pitch : h * pitch;
+ }
+ return Math.min(Math.max(x, 0), (mvs.length - 1) * pitch);
+ });
+ const caretTransform = useTransform(caretX, (x: number) => `translateX(${x}px)`);
+ const washClip = useTransform(openMv, (o: number) => `inset(0 ${(1 - clamp01(o)) * 50}% round ${washRadius}px)`);
+ const checkTransform = useTransform(
+ checkMv,
+ (c: number) => `translateY(${(1 - c) * CHECK_RISE}px) scale(${0.85 + 0.15 * Math.max(c, 0)})`
+ );
+ const checkOpacity = useTransform(checkMv, clamp01);
+
+ const commit = useCallback(
+ (next: string[]) => {
+ const prev = slotsRef.current;
+ slotsRef.current = next;
+ setSlots(next);
+ const code = next.join('');
+ emitted.current = code;
+ onChange?.(code);
+ if (!isFull(prev) && isFull(next)) onComplete?.(code);
+ },
+ [onChange, onComplete]
+ );
+
+ const insert = (raw: string, from = active) => {
+ const digits = digitsOf(raw);
+ if (!digits) return;
+ const next = [...slotsRef.current];
+ const crossed: number[] = [];
+ const step = reduce ? 0 : cascade;
+ let i = from;
+ for (const ch of digits) {
+ if (i >= length) break;
+ next[i] = ch;
+ land(i, (i - from) * step);
+ crossed.push(i);
+ i += 1;
+ }
+ if (!crossed.length) return;
+ commit(next);
+ moveActive(Math.min(i, length - 1), crossed);
+ };
+ const clearSlot = (i: number, stepBack = false) => {
+ if (!slotsRef.current[i]) {
+ if (stepBack) jumpActive(i);
+ return;
+ }
+ const next = [...slotsRef.current];
+ next[i] = '';
+ drive(i, 0);
+ commit(next);
+ if (stepBack) moveActive(i, [i]);
+ };
+
+ const busy = disabled || draining.current || status === 'success';
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (busy || e.metaKey || e.ctrlKey || e.altKey) return;
+ const k = e.key;
+ if (/^[0-9]$/.test(k)) {
+ e.preventDefault();
+ insert(k);
+ } else if (k === 'Backspace') {
+ e.preventDefault();
+ if (slots[active]) clearSlot(active);
+ else if (active > 0) clearSlot(active - 1, true);
+ } else if (k === 'Delete') {
+ e.preventDefault();
+ clearSlot(active);
+ } else if (k === 'ArrowLeft') {
+ e.preventDefault();
+ jumpActive(Math.max(active - 1, 0));
+ } else if (k === 'ArrowRight') {
+ e.preventDefault();
+ jumpActive(Math.min(active + 1, length - 1));
+ } else if (k === 'Home') {
+ e.preventDefault();
+ jumpActive(0);
+ } else if (k === 'End') {
+ e.preventDefault();
+ jumpActive(length - 1);
+ }
+ };
+ const onPaste = (e: React.ClipboardEvent) => {
+ if (busy) return;
+ e.preventDefault();
+ insert(e.clipboardData.getData('text'));
+ };
+ const onInput = (e: React.ChangeEvent) => {
+ if (busy) return;
+ const d = digitsOf(e.target.value);
+ if (!d) return;
+ insert(d, d.length === 1 ? active : 0);
+ };
+ const onRowMouseDown = (e: React.MouseEvent) => {
+ if (disabled) return;
+ e.preventDefault();
+ const row = rowRef.current;
+ if (row && !draining.current && status !== 'success') {
+ const rect = row.getBoundingClientRect();
+ const zoom = rect.width / (row.offsetWidth || rect.width) || 1;
+ const i = Math.floor((e.clientX - rect.left) / zoom / pitch);
+ jumpActive(Math.max(0, Math.min(i, firstEmptyOf(slotsRef.current))));
+ }
+ inputRef.current?.focus();
+ };
+
+ useEffect(() => {
+ glide.current.clear();
+ target.current = [];
+ const next = Array.from({ length }, (_, i) => slotsRef.current[i] ?? '');
+ slotsRef.current = next;
+ setSlots(next);
+ jumpActive(firstEmptyOf(next));
+ const code = next.join('');
+ if (code !== emitted.current) {
+ emitted.current = code;
+ onChange?.(code);
+ }
+ }, [length]);
+
+ useEffect(() => {
+ if (value === undefined) return;
+ const clean = digitsOf(value).slice(0, length);
+ if (clean === emitted.current) return;
+ emitted.current = clean;
+ const prev = slotsRef.current;
+ const next = toSlots(clean, length);
+ const hidden = statusRef.current === 'success';
+ const landing: number[] = [];
+ const leaving: number[] = [];
+ next.forEach((ch, i) => {
+ if (ch === prev[i]) return;
+ (ch ? landing : leaving).push(i);
+ });
+ const step = live.current.reduce || hidden ? 0 : live.current.cascade;
+ landing.forEach((i, k) => land(i, k * step));
+ leaving.reverse().forEach((i, k) => {
+ if (hidden) {
+ target.current[i] = 0;
+ mvs[i].jump(0);
+ drops[i].jump(0);
+ } else drive(i, 0, k * step);
+ });
+ slotsRef.current = next;
+ setSlots(next);
+ moveActive(firstEmptyOf(next), [...landing, ...leaving]);
+ if (!isFull(prev) && isFull(next)) onComplete?.(clean);
+ }, [value, length]);
+
+ useEffect(() => {
+ const was = statusRef.current;
+ const L = live.current;
+ if (status === 'success') {
+ setVeiled(true);
+ if (L.reduce) {
+ openMv.jump(1);
+ drops.forEach(d => d.jump(1));
+ checkMv.jump(1);
+ return;
+ }
+ animate(openMv, 1, { duration: WASH_IN, ease: EASE_OUT });
+ drops.forEach((d, k) =>
+ animate(d, 1, { type: 'spring', duration: 0.3, bounce: 0, delay: SINK_DELAY + k * SINK_STEP })
+ );
+ animate(checkMv, 1, { type: 'spring', duration: 0.35, bounce: L.bounce, delay: CHECK_DELAY });
+ return;
+ }
+ if (was !== 'success') return;
+ if (L.reduce) {
+ openMv.jump(0);
+ checkMv.jump(0);
+ drops.forEach(d => d.jump(0));
+ setVeiled(false);
+ return;
+ }
+ animate(checkMv, 0, { duration: 0.15, ease: EASE_OUT });
+ animate(openMv, 0, { duration: WASH_OUT, ease: EASE_OUT, delay: 0.06 }).then(() => {
+ if (openMv.get() === 0) setVeiled(false);
+ });
+ drops.forEach(d => animate(d, 0, { type: 'spring', duration: 0.3, bounce: 0, delay: 0.1 }));
+ }, [status]);
+
+ useEffect(() => {
+ if (status !== 'error') return;
+ const filled = slotsRef.current.map((c, i) => (c ? i : -1)).filter(i => i >= 0);
+ if (!filled.length) return;
+ filled.reverse();
+ draining.current = true;
+ const L = live.current;
+ const step = L.reduce ? 0 : L.cascade;
+ filled.forEach((i, k) => drive(i, 0, k * step));
+ moveActive(
+ 0,
+ slotsRef.current.map((_, j) => j)
+ );
+ clearTimeout(drainTimer.current);
+ drainTimer.current = setTimeout(
+ () => {
+ draining.current = false;
+ commit(Array.from({ length }, () => ''));
+ },
+ L.reduce ? 300 : (filled.length - 1) * step + L.settle * 1000
+ );
+ }, [status]);
+ useEffect(() => {
+ statusRef.current = status;
+ }, [status]);
+ useEffect(() => () => clearTimeout(drainTimer.current), []);
+ useEffect(() => {
+ if (autoFocus) inputRef.current?.focus();
+ }, [autoFocus]);
+
+ const view = slots.length === length ? slots : Array.from({ length }, (_, i) => slots[i] ?? '');
+ const showCaret =
+ caret && focused && !disabled && !veiled && status !== 'success' && (status === 'error' || !view[active]);
+
+ return (
+
+
+
+ setFocused(true)}
+ onBlur={() => setFocused(false)}
+ />
+ {view.map((ch, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {status === 'success' ? 'Code accepted' : `${view.filter(Boolean).length} of ${length} digits entered`}
+
+
+ );
+};
+
+function Slot({ mv, drop, char, active, rise, sink }: SlotProps) {
+ const [shown, setShown] = useState(char);
+ if (char && char !== shown) setShown(char);
+ const fill = useTransform(mv, (t: number) => `scale(${Math.max(t, 0)})`);
+ const lift = useTransform(
+ [mv, drop],
+ ([t, d]: number[]) => `translateY(${(1 - t) * rise + Math.max(d, 0) * sink}px)`
+ );
+ const ink = useTransform([mv, drop], ([t, d]: number[]) => clamp01(t) * (1 - clamp01(d / SINK_FADE)));
+ return (
+
+
+ {shown ? (
+
+ {shown}
+
+ ) : null}
+
+ );
+}
+
+export default CodeSlots;
diff --git a/src/components/CometDial.tsx b/src/components/CometDial.tsx
new file mode 100644
index 0000000..7d0f555
--- /dev/null
+++ b/src/components/CometDial.tsx
@@ -0,0 +1,446 @@
+'use client';
+
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
+import { animate, useMotionValue, useReducedMotion } from 'motion/react';
+
+const R = 80;
+const DEAD = 44;
+const K = 12;
+const V_FULL = 4;
+const V_FLICK = 2;
+const TAU_FOLLOW = 0.06;
+const TAU_V = 0.05;
+const DECEL = 0.99;
+const DRAG_PX = 10;
+const STALE_MS = 80;
+
+export interface CometDialChangeDetail {
+ velocity: number;
+ bounce: number;
+}
+
+export interface CometDialProps {
+ value?: number;
+ defaultValue?: number;
+ min?: number;
+ max?: number;
+ step?: number;
+ unit?: string;
+ label?: string;
+ accent?: string;
+ ink?: string;
+ size?: number;
+ sweep?: number;
+ thickness?: number;
+ speed?: number;
+ tapBounce?: number;
+ flickBounce?: number;
+ momentum?: number;
+ cometReach?: number;
+ cometWidth?: number;
+ disabled?: boolean;
+ onChange?: (value: number) => void;
+ onChangeEnd?: (value: number, detail: CometDialChangeDetail) => void;
+ className?: string;
+}
+
+type Sample = [number, number];
+
+interface Grip {
+ id: number;
+ at: number | null;
+ hist: Sample[];
+ side: 'hi' | 'lo' | null;
+ moved: boolean;
+ x0: number;
+ y0: number;
+}
+
+interface Loop {
+ raf: number;
+ last: number;
+ v: number;
+ rPrev: number | null;
+ text: string;
+ valueText: string;
+}
+
+interface Live {
+ move: (e: PointerEvent) => void;
+ up: (e: PointerEvent) => void;
+}
+
+const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
+const velocityOf = (hist: Sample[]) => {
+ if (hist.length < 2) return 0;
+ const a = hist[0];
+ const b = hist[hist.length - 1];
+ return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000;
+};
+const decimalsOf = (step: number) => {
+ const s = String(step);
+ const i = s.indexOf('.');
+ return i === -1 ? 0 : s.length - i - 1;
+};
+const pointAt = (deg: number): [number, number] => {
+ const a = (deg * Math.PI) / 180;
+ return [100 + Math.cos(a) * R, 100 + Math.sin(a) * R];
+};
+const arcPath = (a0: number, a1: number) => {
+ const [x0, y0] = pointAt(a0);
+ const [x1, y1] = pointAt(a1);
+ return `M ${x0.toFixed(3)} ${y0.toFixed(3)} A ${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`;
+};
+
+const CometDial: React.FC = ({
+ value,
+ defaultValue = 62,
+ min = 0,
+ max = 100,
+ step = 1,
+ unit = '%',
+ label = 'Level',
+ accent = '#f5f5f5',
+ ink = '#fdfdfd',
+ size = 250,
+ sweep = 320,
+ thickness = 5,
+ speed = 25,
+ tapBounce = 0.2,
+ flickBounce = 0.1,
+ momentum = 1,
+ cometReach = 180,
+ cometWidth = 12,
+ disabled = false,
+ onChange,
+ onChangeEnd,
+ className = ''
+}) => {
+ const reduce = useReducedMotion();
+ const [dragging, setDragging] = useState(false);
+ const svgRef = useRef(null);
+ const litRef = useRef(null);
+ const headRef = useRef(null);
+ const comet = useRef<(SVGPathElement | null)[]>([]);
+ const figure = useRef(null);
+ const target = useRef(clamp(value ?? defaultValue, min, max));
+ const grip = useRef(null);
+ const unbind = useRef<(() => void) | null>(null);
+ const loop = useRef({ raf: 0, last: 0, v: 0, rPrev: null, text: '', valueText: '' });
+ const live = useRef({} as Live);
+ const reading = useMotionValue(target.current);
+
+ const range = Math.max(1e-9, max - min);
+ const gap = 360 - sweep;
+ const start = 90 + gap / 2;
+ const end = start + sweep;
+ const decimals = decimalsOf(step);
+ const k = 200 + (clamp(speed, 0, 100) / 100) * 700;
+ const crit = 2 * Math.sqrt(k);
+ const snap = (v: number) =>
+ step > 0 ? clamp(Math.round((v - min) / step) * step + min, min, max) : clamp(v, min, max);
+
+ const commit = (v: number, finished: boolean, detail?: CometDialChangeDetail) => {
+ const s = snap(v);
+ if (s !== target.current) {
+ target.current = s;
+ onChange?.(s);
+ }
+ if (finished) onChangeEnd?.(s, detail ?? { velocity: 0, bounce: tapBounce });
+ };
+
+ const paintRef = useRef<((now: number) => void) | null>(null);
+ const tick = useCallback((now: number) => paintRef.current?.(now), []);
+ const wake = () => {
+ const L = loop.current;
+ if (L.raf) return;
+ L.last = performance.now();
+ L.rPrev = null;
+ L.raf = requestAnimationFrame(tick);
+ };
+ const launch = (to: number, bounce: number, velocity = reading.getVelocity()) => {
+ reading.stop();
+ if (reduce) {
+ reading.jump(to);
+ wake();
+ return;
+ }
+ animate(reading, to, {
+ type: 'spring',
+ stiffness: k,
+ damping: crit * (1 - clamp(bounce, 0, 0.9)),
+ mass: 1,
+ velocity
+ });
+ wake();
+ };
+
+ const paint = (now: number) => {
+ const L = loop.current;
+ const svg = svgRef.current;
+ if (!svg) {
+ L.raf = 0;
+ return;
+ }
+ const dt = clamp((now - L.last) / 1000, 0, 0.05);
+ L.last = now;
+ const g = grip.current;
+ if (g && g.at != null) {
+ if (reduce) reading.jump(g.at);
+ else reading.set(reading.get() + (g.at - reading.get()) * (1 - Math.exp(-dt / TAU_FOLLOW)));
+ }
+ const r = reading.get();
+ const vRaw = L.rPrev == null || dt === 0 || reduce ? 0 : (r - L.rPrev) / dt / range;
+ L.rPrev = r;
+ L.v += (vRaw - L.v) * (1 - Math.exp(-dt / TAU_V));
+ const v = L.v;
+ const s = clamp(Math.abs(v) / V_FULL, 0, 1);
+ const dir = Math.sign(v) || 1;
+ const f = clamp((r - min) / range, 0, 1);
+ const ang = start + f * sweep;
+ if (litRef.current) litRef.current.setAttribute('d', f > 0.0005 ? arcPath(start, ang) : '');
+ if (headRef.current) {
+ const [cx, cy] = pointAt(ang);
+ headRef.current.setAttribute('cx', cx.toFixed(3));
+ headRef.current.setAttribute('cy', cy.toFixed(3));
+ }
+ const seg = (cometReach * s) / K;
+ for (let j = 0; j < K; j++) {
+ const el = comet.current[j];
+ if (!el) continue;
+ let a0 = dir > 0 ? ang - (j + 1) * seg : ang + j * seg;
+ let a1 = dir > 0 ? ang - j * seg : ang + (j + 1) * seg;
+ a0 = clamp(a0, start, end);
+ a1 = clamp(a1, start, end);
+ if (seg < 0.01 || a1 - a0 < 0.01) {
+ if (el.style.opacity !== '0') el.style.opacity = '0';
+ continue;
+ }
+ const w = 1 - j / K;
+ el.setAttribute('d', arcPath(a0, a1));
+ el.setAttribute('stroke-width', (thickness + cometWidth * w * s).toFixed(2));
+ el.style.opacity = (w * s).toFixed(3);
+ }
+ const shown = clamp(r, min, max).toFixed(decimals);
+ if (shown !== L.text) {
+ L.text = shown;
+ if (figure.current) figure.current.textContent = shown;
+ svg.setAttribute('aria-valuenow', shown);
+ }
+ const valueText = `${target.current}${unit}`;
+ if (valueText !== L.valueText) {
+ L.valueText = valueText;
+ svg.setAttribute('aria-valuetext', valueText);
+ }
+ const moving = !!g || reading.isAnimating() || Math.abs(v) > 0.002;
+ if (!moving) {
+ for (const el of comet.current) if (el && el.style.opacity !== '0') el.style.opacity = '0';
+ }
+ L.raf = moving ? requestAnimationFrame(tick) : 0;
+ };
+ paintRef.current = paint;
+
+ const localPoint = (cx: number, cy: number): [number, number] => {
+ const b = (svgRef.current as SVGSVGElement).getBoundingClientRect();
+ return [((cx - b.left) / b.width) * 200 - 100, ((cy - b.top) / b.height) * 200 - 100];
+ };
+ const angleAt = (cx: number, cy: number) => {
+ const [x, y] = localPoint(cx, cy);
+ let rel = ((Math.atan2(y, x) * 180) / Math.PI - start + 720) % 360;
+ const g = grip.current;
+ if (rel > sweep) rel = g && g.side ? (g.side === 'hi' ? sweep : 0) : rel < sweep + gap / 2 ? sweep : 0;
+ else if (g) g.side = rel > sweep / 2 ? 'hi' : 'lo';
+ return min + (rel / sweep) * range;
+ };
+
+ const move = (e: PointerEvent) => {
+ const g = grip.current;
+ if (!g || g.id !== e.pointerId) return;
+ if (!g.moved) {
+ if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < DRAG_PX) return;
+ g.moved = true;
+ reading.stop();
+ }
+ g.at = angleAt(e.clientX, e.clientY);
+ g.hist.push([performance.now(), g.at]);
+ if (g.hist.length > 4) g.hist.shift();
+ commit(g.at, false);
+ wake();
+ };
+ const up = (e: PointerEvent) => {
+ const g = grip.current;
+ if (!g || g.id !== e.pointerId) return;
+ grip.current = null;
+ unbind.current?.();
+ unbind.current = null;
+ setDragging(false);
+ try {
+ svgRef.current?.releasePointerCapture(e.pointerId);
+ } catch {}
+ if (!g.moved) {
+ commit(target.current, true);
+ return;
+ }
+ const stale = performance.now() - g.hist[g.hist.length - 1][0] > STALE_MS;
+ const v = e.type === 'pointercancel' || stale ? 0 : velocityOf(g.hist);
+ const bounce = tapBounce + (flickBounce - tapBounce) * clamp(Math.abs(v) / range / V_FLICK, 0, 1);
+ const to = snap((g.at ?? target.current) + (v / 1000) * (DECEL / (1 - DECEL)) * momentum);
+ commit(to, true, { velocity: v, bounce });
+ launch(to, bounce, v);
+ };
+ live.current = { move, up };
+
+ const down = (e: React.PointerEvent) => {
+ if (disabled || grip.current || e.button > 0) return;
+ const [x, y] = localPoint(e.clientX, e.clientY);
+ if (Math.hypot(x, y) < DEAD) return;
+ e.preventDefault();
+ const svg = svgRef.current as SVGSVGElement;
+ try {
+ svg.setPointerCapture(e.pointerId);
+ } catch {}
+ svg.focus({ preventScroll: true });
+ grip.current = { id: e.pointerId, at: null, hist: [], side: null, moved: false, x0: e.clientX, y0: e.clientY };
+ const at = angleAt(e.clientX, e.clientY);
+ grip.current.hist.push([performance.now(), at]);
+ setDragging(true);
+ commit(at, false);
+ launch(snap(at), tapBounce);
+ const onMove = (ev: PointerEvent) => {
+ if (ev.isTrusted) live.current.move(ev);
+ };
+ const onUp = (ev: PointerEvent) => {
+ if (ev.isTrusted) live.current.up(ev);
+ };
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ window.addEventListener('pointercancel', onUp);
+ unbind.current = () => {
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ window.removeEventListener('pointercancel', onUp);
+ };
+ };
+
+ const key = (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ const t = target.current;
+ const big = e.shiftKey ? 10 : 1;
+ let to: number;
+ switch (e.key) {
+ case 'ArrowRight':
+ case 'ArrowUp':
+ to = t + step * big;
+ break;
+ case 'ArrowLeft':
+ case 'ArrowDown':
+ to = t - step * big;
+ break;
+ case 'PageUp':
+ to = t + step * 10;
+ break;
+ case 'PageDown':
+ to = t - step * 10;
+ break;
+ case 'Home':
+ to = min;
+ break;
+ case 'End':
+ to = max;
+ break;
+ default:
+ return;
+ }
+ e.preventDefault();
+ to = snap(to);
+ reading.stop();
+ reading.jump(to);
+ commit(to, true);
+ wake();
+ };
+
+ useLayoutEffect(() => {
+ const L = loop.current;
+ L.text = '';
+ L.valueText = '';
+ paintRef.current?.(performance.now());
+ }, [min, max, step, sweep, thickness, cometReach, cometWidth, unit]);
+ useEffect(() => {
+ if (value === undefined || grip.current) return;
+ const s = snap(value);
+ if (s === target.current) return;
+ target.current = s;
+ launch(s, tapBounce);
+ }, [value]);
+ useEffect(
+ () => () => {
+ cancelAnimationFrame(loop.current.raf);
+ unbind.current?.();
+ reading.stop();
+ },
+ [reading]
+ );
+
+ return (
+
+
+
+
+
+ {unit ? {unit} : null}
+
+
+
+ );
+};
+
+export default CometDial;
diff --git a/src/components/HoldButton.tsx b/src/components/HoldButton.tsx
new file mode 100644
index 0000000..d2cbf00
--- /dev/null
+++ b/src/components/HoldButton.tsx
@@ -0,0 +1,358 @@
+'use client';
+
+import React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
+
+export type HoldButtonSize = 'sm' | 'md' | 'lg';
+export type HoldButtonDirection = 'right' | 'up';
+
+export interface HoldButtonProps {
+ children?: ReactNode;
+ doneLabel?: ReactNode;
+ icon?: ReactNode;
+ doneIcon?: ReactNode;
+ backgroundColor?: string;
+ fillColor?: string;
+ textColor?: string;
+ fillTextColor?: string;
+ size?: HoldButtonSize;
+ radius?: number;
+ fillDirection?: HoldButtonDirection;
+ holdTime?: number;
+ releaseTime?: number;
+ pressScale?: number;
+ wave?: boolean;
+ waveAmplitude?: number;
+ glow?: boolean;
+ resetAfter?: number;
+ disabled?: boolean;
+ onHold?: () => void;
+ onTap?: () => void;
+ className?: string;
+}
+
+type Phase = 'idle' | 'holding' | 'done';
+type Input = 'pointer' | 'key' | null;
+
+interface Motion {
+ raf: number;
+ p: number;
+ from: number;
+ to: number;
+ start: number;
+}
+
+interface Gesture {
+ pointerId: number | null;
+ start: number;
+ rect: DOMRect | null;
+}
+
+interface ReleaseOptions {
+ drifted?: boolean;
+}
+
+const TAP_MS = 250;
+const HIT_PAD = 10;
+const LINEAR = (t: number) => t;
+const EASE_OUT = (t: number) => 1 - Math.pow(1 - t, 3);
+
+const SIZES: Record = {
+ sm: 'h-9 px-4 text-[13px]',
+ md: 'h-11 px-[22px] text-[15px]',
+ lg: 'h-[52px] px-7 text-[17px]'
+};
+
+const GLOW = 'inset_0_1px_0_rgba(255,255,255,0.06),0_10px_32px_-6px_color-mix(in_srgb,var(--hb-fill)_70%,transparent)';
+
+const LABEL_SPAN =
+ '[grid-area:1/1] inline-flex items-center gap-2 whitespace-nowrap [transition:opacity_200ms_ease,filter_200ms_ease]';
+
+const STYLE = `
+.hb-root{--hb-w:0px;--hb-h:0px;--hb-cycles:2;--hb-p:0}
+.hb-fill{clip-path:inset(0 calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0)}
+.hb-root[data-direction=up] .hb-fill{clip-path:inset(calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0 0)}
+.hb-crest{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E");-webkit-mask-repeat:repeat-y;mask-repeat:repeat-y;-webkit-mask-size:var(--hb-wave) calc(var(--hb-h) * 2);mask-size:var(--hb-wave) calc(var(--hb-h) * 2);-webkit-mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));-webkit-mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h));mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h))}
+.hb-root[data-direction=up] .hb-crest{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E");-webkit-mask-repeat:repeat-x;mask-repeat:repeat-x;-webkit-mask-size:calc(var(--hb-w) * 2) var(--hb-wave);mask-size:calc(var(--hb-w) * 2) var(--hb-wave);-webkit-mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));-webkit-mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)));mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)))}
+@keyframes hb-pulse{from{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb,var(--hb-fill) 55%,transparent)}to{opacity:0;box-shadow:0 0 0 14px color-mix(in srgb,var(--hb-fill) 0%,transparent)}}
+@media (prefers-reduced-motion:reduce){
+.hb-root{transform:none!important;transition:background-color 160ms ease,box-shadow var(--hb-release) ease!important}
+.hb-fill{clip-path:inset(0)!important;opacity:0;transition:opacity var(--hb-release) ease!important}
+.hb-crest{display:none}
+.hb-root[data-phase=holding] .hb-fill,.hb-root[data-phase=done] .hb-fill{opacity:1;transition:opacity var(--hb-hold) linear!important}
+.hb-pulse{animation:none!important}
+.hb-label>span{filter:none!important;transition:opacity 200ms ease!important}
+}`;
+
+const HoldButton: React.FC = ({
+ children = 'Hold to delete',
+ doneLabel = 'Deleted',
+ icon = null,
+ doneIcon = null,
+ backgroundColor = '#27272a',
+ fillColor = '#5227FF',
+ textColor = '#f5f5f5',
+ fillTextColor = '#ffffff',
+ size = 'md',
+ radius = 14,
+ fillDirection = 'right',
+ holdTime = 2000,
+ releaseTime = 200,
+ pressScale = 0.97,
+ wave = true,
+ waveAmplitude = 6,
+ glow = true,
+ resetAfter = 1200,
+ disabled = false,
+ onHold,
+ onTap,
+ className = ''
+}) => {
+ const [phase, setPhase] = useState('idle');
+ const [input, setInput] = useState(null);
+ const phaseRef = useRef('idle');
+ const inputRef = useRef(null);
+ const buttonRef = useRef(null);
+ const gesture = useRef({ pointerId: null, start: 0, rect: null });
+ const timers = useRef({ complete: 0, reset: 0 });
+ const hintId = useId();
+
+ const go = (next: Phase, kind: Input = null) => {
+ phaseRef.current = next;
+ inputRef.current = kind;
+ setPhase(next);
+ setInput(kind);
+ };
+
+ const clearTimers = () => {
+ clearTimeout(timers.current.complete);
+ clearTimeout(timers.current.reset);
+ };
+
+ const motion = useRef({ raf: 0, p: 0, from: 0, to: 0, start: 0 });
+ const drive = (to: number, duration: number, ease: (t: number) => number) => {
+ const m = motion.current;
+ cancelAnimationFrame(m.raf);
+ m.from = m.p;
+ m.to = to;
+ m.start = performance.now();
+ const step = (now: number) => {
+ const t = duration > 0 ? Math.min(1, (now - m.start) / duration) : 1;
+ m.p = m.from + (m.to - m.from) * ease(t);
+ buttonRef.current?.style.setProperty('--hb-p', m.p.toFixed(4));
+ if (t < 1) {
+ m.raf = requestAnimationFrame(step);
+ return;
+ }
+ m.raf = 0;
+ if (m.to === 1) complete();
+ };
+ m.raf = requestAnimationFrame(step);
+ };
+
+ const complete = () => {
+ if (phaseRef.current !== 'holding') return;
+ if (performance.now() - gesture.current.start < holdTime - 50) return;
+ clearTimers();
+ go('done', inputRef.current);
+ onHold?.();
+ if (resetAfter > 0) {
+ timers.current.reset = window.setTimeout(() => {
+ go('idle');
+ drive(0, releaseTime, EASE_OUT);
+ }, resetAfter);
+ }
+ };
+
+ const begin = (kind: Input) => {
+ if (disabled || phaseRef.current !== 'idle') return false;
+ const button = buttonRef.current;
+ if (!button) return false;
+ gesture.current.start = performance.now();
+ gesture.current.rect = button.getBoundingClientRect();
+ go('holding', kind);
+ drive(1, holdTime, LINEAR);
+ timers.current.complete = window.setTimeout(complete, holdTime + 100);
+ return true;
+ };
+
+ const release = ({ drifted = false }: ReleaseOptions = {}) => {
+ if (phaseRef.current !== 'holding') return;
+ clearTimers();
+ const held = performance.now() - gesture.current.start;
+ go('idle');
+ drive(0, releaseTime, EASE_OUT);
+ if (!drifted && held < TAP_MS) onTap?.();
+ };
+ const releaseRef = useRef(release);
+ releaseRef.current = release;
+
+ const handlePointerDown = (e: React.PointerEvent) => {
+ if (e.button !== 0 || !e.isPrimary || gesture.current.pointerId !== null) return;
+ if (!begin('pointer')) return;
+ gesture.current.pointerId = e.pointerId;
+ try {
+ e.currentTarget.setPointerCapture(e.pointerId);
+ } catch {}
+ };
+
+ const endPointer = (e: React.PointerEvent, options?: ReleaseOptions) => {
+ if (e.pointerId !== gesture.current.pointerId) return;
+ gesture.current.pointerId = null;
+ try {
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
+ } catch {}
+ release(options);
+ };
+
+ const handlePointerMove = (e: React.PointerEvent) => {
+ if (e.pointerId !== gesture.current.pointerId) return;
+ const r = gesture.current.rect;
+ if (!r) return;
+ const out =
+ e.clientX < r.left - HIT_PAD ||
+ e.clientX > r.right + HIT_PAD ||
+ e.clientY < r.top - HIT_PAD ||
+ e.clientY > r.bottom + HIT_PAD;
+ if (out) endPointer(e, { drifted: true });
+ };
+
+ const handlePointerLeave = (e: React.PointerEvent) => {
+ if (e.pointerType !== 'touch') endPointer(e, { drifted: true });
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ if (inputRef.current === 'key') release({ drifted: true });
+ return;
+ }
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ if (!e.repeat) begin('key');
+ }
+ };
+
+ const handleKeyUp = (e: React.KeyboardEvent) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ if (inputRef.current === 'key') release();
+ }
+ };
+
+ useLayoutEffect(() => {
+ const button = buttonRef.current;
+ if (!button) return undefined;
+ const measure = () => {
+ button.style.setProperty('--hb-w', `${button.offsetWidth}px`);
+ button.style.setProperty('--hb-h', `${button.offsetHeight}px`);
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(button);
+ return () => ro.disconnect();
+ }, []);
+
+ useEffect(() => {
+ if (phase !== 'holding') return undefined;
+ const cancel = () => releaseRef.current({ drifted: true });
+ const onVisibility = () => {
+ if (document.hidden) cancel();
+ };
+ window.addEventListener('blur', cancel);
+ document.addEventListener('visibilitychange', onVisibility);
+ return () => {
+ window.removeEventListener('blur', cancel);
+ document.removeEventListener('visibilitychange', onVisibility);
+ };
+ }, [phase]);
+
+ useEffect(() => {
+ const t = timers.current;
+ const m = motion.current;
+ return () => {
+ clearTimeout(t.complete);
+ clearTimeout(t.reset);
+ cancelAnimationFrame(m.raf);
+ };
+ }, []);
+
+ const direction: HoldButtonDirection = fillDirection === 'up' ? 'up' : 'right';
+ const labels = (
+ <>
+
+ {icon ? {icon} : null}
+ {children}
+
+
+ {doneIcon ? {doneIcon} : null}
+ {doneLabel}
+
+ >
+ );
+
+ const cssVars = {
+ '--hb-radius': `${radius}px`,
+ '--hb-bg': backgroundColor,
+ '--hb-fill': fillColor,
+ '--hb-text': textColor,
+ '--hb-fill-text': fillTextColor,
+ '--hb-hold': `${holdTime}ms`,
+ '--hb-cycles': holdTime / 1100,
+ '--hb-release': `${releaseTime}ms`,
+ '--hb-press': pressScale,
+ '--hb-wave': `${wave ? waveAmplitude : 0}px`,
+ '--hb-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)'
+ } as CSSProperties;
+
+ return (
+
+ );
+};
+
+export default HoldButton;
diff --git a/src/components/SwipeRow.tsx b/src/components/SwipeRow.tsx
new file mode 100644
index 0000000..66e0cc2
--- /dev/null
+++ b/src/components/SwipeRow.tsx
@@ -0,0 +1,490 @@
+'use client';
+
+import React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
+import { animate, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react';
+import { HugeiconsIcon } from '@hugeicons/react';
+import { Delete02Icon } from '@hugeicons/core-free-icons';
+
+const HYST = 10;
+const FLICK = 110;
+const DECEL = 0.998;
+const VMAX = 1500;
+const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
+const SPRING_UI = { type: 'spring' as const, duration: 0.3, bounce: 0 };
+const DEFAULT_ACTIONS: SwipeAction[] = [{ id: 'delete', label: 'Delete' }];
+
+export interface SwipeAction {
+ id: string;
+ label: string;
+ icon?: ReactNode;
+ color?: string;
+ dismiss?: boolean;
+ onSelect?: () => void;
+}
+
+export interface SwipeRowProps {
+ children?: ReactNode;
+ actions?: SwipeAction[];
+ actionColor?: string;
+ drawerColor?: string;
+ rowColor?: string;
+ textColor?: string;
+ height?: number;
+ radius?: number;
+ actionWidth?: number;
+ direction?: 'left' | 'right';
+ snapBounce?: number;
+ resistance?: number;
+ collapseMs?: number;
+ commitAt?: number;
+ fullSwipe?: boolean;
+ disabled?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onAction?: (action: SwipeAction) => void;
+ onCommit?: (action: SwipeAction) => void;
+ closeOnAction?: boolean;
+ haptic?: boolean;
+ label?: string;
+ className?: string;
+ style?: CSSProperties;
+}
+
+type Sample = [number, number];
+type Phase = 'idle' | 'committing' | 'collapsing';
+
+interface Grip {
+ id: number;
+ x0: number;
+ y0: number;
+ grab: number | null;
+ moved: boolean;
+ hist: Sample[];
+ touch: boolean;
+}
+
+interface Live {
+ move: (e: PointerEvent) => void;
+ up: (e: PointerEvent) => void;
+}
+
+const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
+const rubber = (o: number, dim: number, c: number) => (o * dim * c) / (dim + c * Math.abs(o));
+const unrubber = (y: number, dim: number, c: number) => (y * dim) / (c * Math.max(1, dim - Math.abs(y)));
+const project = (v: number) => ((v / 1000) * DECEL) / (1 - DECEL);
+const velocityOf = (hist: Sample[]) => {
+ if (hist.length < 2) return 0;
+ const a = hist[0];
+ const b = hist[hist.length - 1];
+ return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000;
+};
+const onColor = (hex: string) => {
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
+ if (!m) return '#ffffff';
+ const h = m[1].length === 3 ? [...m[1]].map(ch => ch + ch).join('') : m[1];
+ const r = parseInt(h.slice(0, 2), 16);
+ const g = parseInt(h.slice(2, 4), 16);
+ const b = parseInt(h.slice(4, 6), 16);
+ return (r * 299 + g * 587 + b * 114) / 1000 >= 150 ? '#111111' : '#ffffff';
+};
+const watchWindow = (live: { current: Live }) => {
+ const onMove = (e: PointerEvent) => {
+ if (e.isTrusted) live.current.move(e);
+ };
+ const onUp = (e: PointerEvent) => {
+ if (e.isTrusted) live.current.up(e);
+ };
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ window.addEventListener('pointercancel', onUp);
+ return () => {
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ window.removeEventListener('pointercancel', onUp);
+ };
+};
+
+const SwipeRow: React.FC = ({
+ children,
+ actions = DEFAULT_ACTIONS,
+ actionColor = '#e5484d',
+ drawerColor = '#3f3f46',
+ rowColor = '#27272a',
+ textColor = '#f5f5f5',
+ height = 64,
+ radius = 16,
+ actionWidth = 80,
+ direction = 'left',
+ snapBounce = 0.2,
+ resistance = 0.55,
+ collapseMs = 200,
+ commitAt = 0.6,
+ fullSwipe = true,
+ disabled = false,
+ open: openProp,
+ onOpenChange,
+ onAction,
+ onCommit,
+ closeOnAction = true,
+ haptic = true,
+ label = 'List item',
+ className = '',
+ style
+}) => {
+ const uid = useId();
+ const reduce = useReducedMotion();
+ const s = direction === 'left' ? -1 : 1;
+ const A = actionWidth;
+ const n = actions.length;
+ const D = n * A;
+ const c = clamp(resistance, 0.05, 1);
+ const primary = actions[0];
+ const [openState, setOpenState] = useState(false);
+ const [phase, setPhase] = useState('idle');
+ const [say, setSay] = useState('');
+ const open = openProp ?? openState;
+
+ const root = useRef(null);
+ const surface = useRef(null);
+ const w = useRef(360);
+ const grip = useRef(null);
+ const unwatch = useRef<(() => void) | null>(null);
+ const live = useRef({} as Live);
+ const foldTimer = useRef | undefined>(undefined);
+ const heading = useRef(null);
+
+ const x = useMotionValue(0);
+ const spread = useMotionValue(0);
+ const landed = useMotionValue(0);
+ const commitPoint = () => Math.max(commitAt * w.current, D + A / 2);
+ const canCommit = () => fullSwipe && n > 0 && commitPoint() <= w.current;
+ const exposed = useTransform(x, v => s * v);
+ const surfaceXf = useTransform(x, v => `translateX(${v}px)`);
+ const railXf = useTransform(exposed, (e: number) => `translateX(${-s * Math.max(0, D - e)}px)`);
+ const shift = useTransform([exposed, spread], ([e, p]: number[]) => p * Math.max(0, e - A));
+ const blockXf = useTransform(shift, v => `translateX(${s * v}px)`);
+ const glyphXf = useTransform(
+ [shift, landed],
+ ([v, l]: number[]) => `translateX(${-s * l * (v - (w.current - A) / 2)}px)`
+ );
+
+ const map = (raw: number) => {
+ const W = w.current;
+ if (raw < 0) return rubber(raw, W, c);
+ if (raw <= D) return raw;
+ if (!canCommit()) return D + rubber(raw - D, W, c);
+ const C = commitPoint();
+ const knee = D + (C - D) / c;
+ return raw <= knee ? D + c * (raw - D) : C + rubber(raw - knee, W, c);
+ };
+ const inv = (ex: number) => {
+ const W = w.current;
+ if (ex < 0) return unrubber(ex, W, c);
+ if (ex <= D) return ex;
+ if (!canCommit()) return D + unrubber(ex - D, W, c);
+ const C = commitPoint();
+ const knee = D + (C - D) / c;
+ return ex <= C ? D + (ex - D) / c : knee + unrubber(ex - C, W, c);
+ };
+
+ useLayoutEffect(() => {
+ const el = root.current;
+ if (!el) return undefined;
+ w.current = el.offsetWidth || w.current;
+ const observer = new ResizeObserver(entries => {
+ const width = entries[0]?.contentRect.width;
+ if (width) w.current = width;
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+ useEffect(
+ () => () => {
+ clearTimeout(foldTimer.current);
+ unwatch.current?.();
+ },
+ []
+ );
+
+ const setOpen = (next: boolean) => {
+ if (next === open) return;
+ setOpenState(next);
+ onOpenChange?.(next);
+ };
+ const settle = (target: number, v = 0) => {
+ heading.current = target;
+ if (reduce) {
+ animate(x, s * target, { duration: 0.2, ease: EASE_OUT });
+ return;
+ }
+ const flick = Math.abs(v) >= FLICK;
+ animate(
+ x,
+ s * target,
+ flick
+ ? { type: 'spring', duration: 0.4, bounce: snapBounce, velocity: s * clamp(v, -VMAX, VMAX) }
+ : { ...SPRING_UI, velocity: s * v }
+ );
+ };
+ const setSpread = (on: boolean) => {
+ if ((spread.get() === 1) === on) return;
+ if (reduce) spread.set(on ? 1 : 0);
+ else animate(spread, on ? 1 : 0, SPRING_UI);
+ if (on && primary) {
+ setSay(`Release to ${primary.label}`);
+ if (haptic && grip.current?.touch) navigator.vibrate?.(8);
+ }
+ };
+ const commit = (a: SwipeAction, viaKey: boolean, v = 0) => {
+ const leap = a === primary;
+ setPhase('committing');
+ setSay(a.label);
+ setOpen(false);
+ const fold = () => {
+ setPhase('collapsing');
+ foldTimer.current = setTimeout(() => {
+ onCommit?.(a);
+ a.onSelect?.();
+ }, collapseMs);
+ };
+ if (viaKey || reduce) {
+ if (leap) {
+ spread.set(1);
+ landed.set(1);
+ }
+ if (viaKey) {
+ x.set(s * w.current);
+ fold();
+ } else animate(x, s * w.current, { duration: 0.2, ease: EASE_OUT }).then(fold);
+ return;
+ }
+ if (leap) {
+ if (spread.get() < 1) animate(spread, 1, SPRING_UI);
+ animate(landed, 1, SPRING_UI);
+ }
+ animate(x, s * w.current, { ...SPRING_UI, velocity: s * v }).then(fold);
+ };
+ useEffect(() => {
+ if (openProp === undefined || grip.current || phase !== 'idle') return;
+ const target = open ? D : 0;
+ if (heading.current === target) return;
+ if (Math.abs(exposed.get() - target) > 0.5) settle(target);
+ }, [open, D]);
+
+ const down = (e: React.PointerEvent) => {
+ if (disabled || n === 0 || phase !== 'idle' || grip.current || e.button !== 0) return;
+ x.stop();
+ heading.current = null;
+ grip.current = {
+ id: e.pointerId,
+ x0: e.clientX,
+ y0: e.clientY,
+ grab: null,
+ moved: false,
+ hist: [],
+ touch: e.pointerType === 'touch'
+ };
+ try {
+ surface.current?.setPointerCapture(e.pointerId);
+ } catch {}
+ unwatch.current?.();
+ unwatch.current = watchWindow(live);
+ };
+ const move = (e: PointerEvent) => {
+ const g = grip.current;
+ if (!g || g.id !== e.pointerId) return;
+ if (g.grab === null) {
+ const dx = e.clientX - g.x0;
+ const dy = e.clientY - g.y0;
+ if (Math.abs(dx) < HYST || Math.abs(dx) < Math.abs(dy)) return;
+ g.grab = s * (g.x0 + Math.sign(dx) * HYST) - inv(exposed.get());
+ g.moved = true;
+ root.current?.setAttribute('data-dragging', '');
+ }
+ const ex = map(s * e.clientX - g.grab);
+ x.set(s * ex);
+ g.hist.push([performance.now(), ex]);
+ if (g.hist.length > 4) g.hist.shift();
+ setSpread(canCommit() && ex >= commitPoint());
+ };
+ const up = (e: PointerEvent) => {
+ const g = grip.current;
+ if (!g || g.id !== e.pointerId) return;
+ grip.current = null;
+ unwatch.current?.();
+ unwatch.current = null;
+ root.current?.removeAttribute('data-dragging');
+ try {
+ surface.current?.releasePointerCapture(e.pointerId);
+ } catch {}
+ const ex = exposed.get();
+ const v = velocityOf(g.hist);
+ if (!g.moved) {
+ if (open) {
+ setOpen(false);
+ settle(0);
+ }
+ return;
+ }
+ if (primary && canCommit() && ex >= commitPoint()) {
+ commit(primary, false, v);
+ return;
+ }
+ const target = Math.abs(v) >= FLICK ? (v > 0 ? D : 0) : ex + project(v) > D / 2 ? D : 0;
+ setSpread(false);
+ setOpen(target === D);
+ settle(target, v);
+ };
+ live.current = { move, up };
+
+ const act = (a: SwipeAction, e: React.MouseEvent) => {
+ if (phase !== 'idle') return;
+ onAction?.(a);
+ if (a === primary || a.dismiss) {
+ commit(a, e.detail === 0);
+ return;
+ }
+ a.onSelect?.();
+ if (!closeOnAction) return;
+ setOpen(false);
+ if (e.detail === 0) {
+ heading.current = 0;
+ x.set(0);
+ } else settle(0);
+ };
+ const openNow = () => {
+ heading.current = D;
+ x.set(s * D);
+ setOpen(true);
+ setSay(`${n} actions revealed`);
+ };
+ const closeNow = () => {
+ heading.current = 0;
+ x.set(0);
+ setOpen(false);
+ };
+ const onToggleKey = (e: React.KeyboardEvent) => {
+ if (disabled || phase !== 'idle' || n === 0) return;
+ const openKey = s < 0 ? 'ArrowLeft' : 'ArrowRight';
+ const closeKey = s < 0 ? 'ArrowRight' : 'ArrowLeft';
+ const toggle = e.key === 'Enter' || e.key === ' ';
+ if (e.key === openKey || (toggle && !open)) {
+ e.preventDefault();
+ openNow();
+ } else if (e.key === closeKey || e.key === 'Escape' || (toggle && open)) {
+ e.preventDefault();
+ closeNow();
+ } else if ((e.key === 'Delete' || e.key === 'Backspace') && open && primary && canCommit()) {
+ e.preventDefault();
+ commit(primary, true);
+ }
+ };
+ const onToggleClick = (e: React.MouseEvent) => {
+ if (e.detail !== 0 || disabled || phase !== 'idle' || n === 0) return;
+ if (open) closeNow();
+ else openNow();
+ };
+
+ const railId = `${uid}-rail`;
+ return (
+
+
+
+ {actions.slice(1).map((a, i) => (
+
+ ))}
+ {primary ? (
+
+ act(primary, e)}
+ >
+
+
+ {primary.icon ?? }
+
+ {primary.label}
+
+
+
+ ) : null}
+
+
+ {children}
+
+
+
+
+ {say}
+
+
+ );
+};
+
+export default SwipeRow;
diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx
index 36226fa..537a555 100755
--- a/src/components/aos/app-shell.tsx
+++ b/src/components/aos/app-shell.tsx
@@ -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
+ // Before mount: render a minimal skeleton to prevent hydration mismatch
+ if (!mounted) {
+ return (
+
+ )
+ }
+
return (
{/* Desktop sidebar */}
diff --git a/src/components/aos/fuse-cta.tsx b/src/components/aos/fuse-cta.tsx
new file mode 100644
index 0000000..5c65a50
--- /dev/null
+++ b/src/components/aos/fuse-cta.tsx
@@ -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
+ 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 (
+
+ )
+}
diff --git a/src/components/aos/hold-to-confirm.tsx b/src/components/aos/hold-to-confirm.tsx
new file mode 100644
index 0000000..7490eed
--- /dev/null
+++ b/src/components/aos/hold-to-confirm.tsx
@@ -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
+ 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 (
+
+ {holding ? (
+
+
+ Ausführen…
+
+ ) : (
+ children
+ )}
+
+ )
+}
diff --git a/src/components/aos/slide-to-lock.tsx b/src/components/aos/slide-to-lock.tsx
new file mode 100644
index 0000000..a0d0641
--- /dev/null
+++ b/src/components/aos/slide-to-lock.tsx
@@ -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
+ 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 (
+
+ {}}
+ 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' ? : }
+ />
+
+ )
+}
diff --git a/src/components/aos/swipeable-lead-card.tsx b/src/components/aos/swipeable-lead-card.tsx
new file mode 100644
index 0000000..aadb605
--- /dev/null
+++ b/src/components/aos/swipeable-lead-card.tsx
@@ -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 = {
+ '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: , color: 'oklch(0.45 0 0)', dismiss: false },
+ { id: 'snooze', label: 'Snooze', icon: , color: 'oklch(0.6 0 0)', dismiss: false },
+ { id: 'reject', label: 'Ablehnen', icon: , 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 (
+
+
+
+ {lead.score}
+
+
+
{lead.companyName}
+
+ {lead.website && {lead.website}}
+ {lead.city && <>·{lead.city}>}
+
+ {lead.scoreLabel && SCORE_LABELS[lead.scoreLabel] && (
+
+ {SCORE_LABELS[lead.scoreLabel]}
+
+ )}
+
+
{formatRelative(lead.createdAt)}
+
+
+ )
+}
diff --git a/src/components/aos/views/contracts.tsx b/src/components/aos/views/contracts.tsx
index 893566f..20763d4 100755
--- a/src/components/aos/views/contracts.tsx
+++ b/src/components/aos/views/contracts.tsx
@@ -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() {
{canSign && (
-
+ sign(selected)}
+ holdTime={1500}
+ size="md"
+ disabled={signing}
+ >
+
+
+ E-Signatur anfordern
+
+
)}
>
diff --git a/src/components/aos/views/leads.tsx b/src/components/aos/views/leads.tsx
index ba81923..9272132 100755
--- a/src/components/aos/views/leads.tsx
+++ b/src/components/aos/views/leads.tsx
@@ -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() {
{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 (
+
+ { 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') }}
+ />
+
+ )
+ }
+ // Non-pending leads keep the simple button card
return (
+
}
/>
diff --git a/src/components/aos/views/timesheet.tsx b/src/components/aos/views/timesheet.tsx
index c165d30..0e0e7f6 100755
--- a/src/components/aos/views/timesheet.tsx
+++ b/src/components/aos/views/timesheet.tsx
@@ -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 ? (
-
+
) : stats.allLocked ? (
Gesperrt