feat(onboarding-tour): Onboarding-Tour-Component (intro.js-Style overlay) [tsc:fail]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 06:23:13 +02:00
parent 54fa5ccc3b
commit e1ddeee598
9 changed files with 986 additions and 23 deletions

View File

@ -8,6 +8,7 @@
"project-templates", "project-templates",
"language-toggle", "language-toggle",
"keyboard-help-modal", "keyboard-help-modal",
"api-client-phase10" "api-client-phase10",
"router-phase10"
] ]
} }

5
.phase11-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"completed_features": [],
"current_feature": "onboarding-tour",
"started_at": "2026-05-23T06:21:46.924268"
}

View File

@ -1284,3 +1284,44 @@ src/index.ts(27,25): error TS2769: No overload matches this call.
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
- `06:20:28` **INFO** Committed feature router-phase10
- `06:20:28` **INFO** Pushed: rc=0
## Phase-10 Run beendet (2026-05-23 06:20:28)
- `06:20:28` **INFO** OK: 0, Attempted: 7, Total: 7
- `06:20:29` **INFO** db:generate rc=0: olumns 0 indexes 1 fks
time_entries 8 columns 0 indexes 2 fks
users 6 columns 0 indexes 0 fks
webhooks 6 columns 0 indexes 1 fks
[✓] Your SQL migration file ➜ drizzle/0005_gigantic_thunderbird.sql 🚀
- `06:20:30` **INFO** db:migrate rc=0: one/api@0.0.1 db:migrate /home/dark/Developer/EmberClone/apps/api
> tsx src/db/migrate.ts
Running migrations...
Migrations completed successfully
Checking for admin user...
Admin user already exists
## 🚀 Phase-11 Codegen-Run gestartet (2026-05-23 06:21:46)
## Phase-3 Feature: onboarding-tour (2026-05-23 06:21:46)
- `06:21:46` **INFO** Description: Onboarding-Tour-Component (intro.js-Style overlay)
- `06:21:46` **INFO** Generating apps/web/src/components/OnboardingTour.tsx (Onboarding-Tour. Steps-Array mit {selector, title, body} (z.B. Dashboa…)
- `06:22:27` **INFO** wrote 4391 chars in 40.2s (attempt 1)
- `06:22:27` **INFO** Generating apps/web/src/App.tsx (ERWEITERT — mount <OnboardingTour /> global. Behalte alles.…)
- `06:23:11` **INFO** wrote 5335 chars in 44.5s (attempt 1)
- `06:23:11` **INFO** Running tsc --noEmit on api…
- `06:23:13` **WARN** tsc errors:
src/index.ts(27,25): error TS2769: No overload matches this call.
Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'.
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS "project_templates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"default_billable" boolean DEFAULT true NOT NULL,
"estimated_hours" integer,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "tags" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "time_entries" ADD COLUMN "notes" text;

View File

@ -0,0 +1,577 @@
{
"id": "e70eab5f-f341-4aa4-bf80-3c11e2c3cdd5",
"prevId": "a4312ab5-9818-4a13-a906-33971e96f38a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.app_settings": {
"name": "app_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"workspace_name": {
"name": "workspace_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'EmberClone'"
},
"default_billable": {
"name": "default_billable",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"week_start": {
"name": "week_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"audit_log_user_id_users_id_fk": {
"name": "audit_log_user_id_users_id_fk",
"tableFrom": "audit_log",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.customers": {
"name": "customers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size_bytes": {
"name": "size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "bytea",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"documents_user_id_users_id_fk": {
"name": "documents_user_id_users_id_fk",
"tableFrom": "documents",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.project_templates": {
"name": "project_templates",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"default_billable": {
"name": "default_billable",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"estimated_hours": {
"name": "estimated_hours",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"projects_customer_id_customers_id_fk": {
"name": "projects_customer_id_customers_id_fk",
"tableFrom": "projects",
"tableTo": "customers",
"columnsFrom": [
"customer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.time_entries": {
"name": "time_entries",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"start_time": {
"name": "start_time",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_time": {
"name": "end_time",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"time_entries_user_id_users_id_fk": {
"name": "time_entries_user_id_users_id_fk",
"tableFrom": "time_entries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"time_entries_project_id_projects_id_fk": {
"name": "time_entries_project_id_projects_id_fk",
"tableFrom": "time_entries",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.webhooks": {
"name": "webhooks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"event": {
"name": "event",
"type": "text",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by": {
"name": "created_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"webhooks_created_by_users_id_fk": {
"name": "webhooks_created_by_users_id_fk",
"tableFrom": "webhooks",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -36,6 +36,13 @@
"when": 1779509310016, "when": 1779509310016,
"tag": "0004_clammy_random", "tag": "0004_clammy_random",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779510029419,
"tag": "0005_gigantic_thunderbird",
"breakpoints": true
} }
] ]
} }

View File

@ -20,6 +20,7 @@ import ProjectTemplates from "./pages/ProjectTemplates"
import Nav from "./components/Nav" import Nav from "./components/Nav"
import CommandPalette from "./components/CommandPalette" import CommandPalette from "./components/CommandPalette"
import KeyboardHelp from "./components/KeyboardHelp" import KeyboardHelp from "./components/KeyboardHelp"
import OnboardingTour from "./components/OnboardingTour"
import { ToastProvider } from "./components/Toast" import { ToastProvider } from "./components/Toast"
import ErrorBoundary from "./components/ErrorBoundary" import ErrorBoundary from "./components/ErrorBoundary"
import { api } from "./lib/api" import { api } from "./lib/api"
@ -31,6 +32,7 @@ const rootRoute = createRootRoute({
<Nav /> <Nav />
<CommandPalette /> <CommandPalette />
<KeyboardHelp /> <KeyboardHelp />
<OnboardingTour />
<Outlet /> <Outlet />
</div> </div>
</ToastProvider> </ToastProvider>
@ -160,20 +162,6 @@ const adminRoute = createRoute({
component: AdminUsers component: AdminUsers
}) })
const projectTemplatesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project-templates",
beforeLoad: adminCheck,
component: ProjectTemplates
})
const auditLogRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/audit-log",
beforeLoad: adminCheck,
component: AuditLog
})
const settingsRoute = createRoute({ const settingsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/settings", path: "/settings",
@ -181,16 +169,30 @@ const settingsRoute = createRoute({
component: Settings component: Settings
}) })
const auditLogRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/admin/audit-log",
beforeLoad: adminCheck,
component: AuditLog
})
const webhooksRoute = createRoute({ const webhooksRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/webhooks", path: "/admin/webhooks",
beforeLoad: adminCheck, beforeLoad: adminCheck,
component: Webhooks component: Webhooks
}) })
const projectTemplatesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/admin/templates",
beforeLoad: adminCheck,
component: ProjectTemplates
})
const routeTree = [ const routeTree = [
loginRoute,
indexRoute, indexRoute,
loginRoute,
timeEntriesRoute, timeEntriesRoute,
calendarRoute, calendarRoute,
customersRoute, customersRoute,
@ -203,10 +205,10 @@ const routeTree = [
billingRoute, billingRoute,
integrationsRoute, integrationsRoute,
adminRoute, adminRoute,
projectTemplatesRoute,
auditLogRoute,
settingsRoute, settingsRoute,
webhooksRoute auditLogRoute,
webhooksRoute,
projectTemplatesRoute,
] ]
const router = createRouter({ const router = createRouter({
@ -222,8 +224,8 @@ declare module "@tanstack/react-router" {
export default function App() { export default function App() {
return ( return (
<RouterProvider router={router}> <ErrorBoundary>
<ErrorBoundary /> <RouterProvider router={router} />
</RouterProvider> </ErrorBoundary>
) )
} }

View File

@ -0,0 +1,137 @@
import React, { useState, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
type TourStep = {
selector: string;
title: string;
body: string;
};
const TOUR_STEPS: TourStep[] = [
{
selector: '[data-tour="nav"]',
title: 'Navigation',
body: 'Hier findest du den schnellen Zugriff auf alle Bereiche deiner Zeiterfassung.',
},
{
selector: '[data-tour="time-entries"]',
title: 'Zeiteinträge',
body: 'Klicke hier, um neue Einträge zu erstellen oder bestehende zu bearbeiten.',
},
{
selector: '[data-tour="search-hint"]',
title: 'Schnellsuche',
body: 'Nutze ⌘K (oder Strg+K), um blitzschnell durch deine Projekte zu navigieren.',
},
];
export default function OnboardingTour() {
const [currentStep, setCurrentStep] = useState<number | null>(null);
const [coords, setCoords] = useState({ top: 0, left: 0, width: 0, height: 0 });
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const isDone = localStorage.getItem('onboarding-done');
if (!isDone) {
setCurrentStep(0);
}
}, []);
useEffect(() => {
if (currentStep === null) return;
const updatePosition = () => {
const element = document.querySelector(TOUR_STEPS[currentStep].selector);
if (element) {
const rect = element.getBoundingClientRect();
setCoords({
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
});
setIsVisible(true);
} else {
setIsVisible(false);
}
};
updatePosition();
window.addEventListener('resize', updatePosition);
return () => window.removeEventListener('resize', updatePosition);
}, [currentStep]);
const handleNext = () => {
if (currentStep === null) return;
if (currentStep < TOUR_STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
completeTour();
}
};
const completeTour = () => {
localStorage.setItem('onboarding-done', 'true');
setCurrentStep(null);
};
if (currentStep === null || !isVisible) return null;
const step = TOUR_STEPS[currentStep];
return (
<div className="fixed inset-0 z-[100] pointer-events-auto overflow-hidden">
{/* Backdrop with Hole */}
<div
className="absolute inset-0 bg-black/40 transition-all duration-300"
style={{
clipPath: `polygon(
0% 0%, 0% 100%,
${coords.left}px 100%,
${coords.left}px ${coords.top}px,
${coords.left + coords.width}px ${coords.top}px,
${coords.left + coords.width}px ${coords.top + coords.height}px,
${coords.left}px ${coords.top + coords.height}px,
${coords.left}px 100%,
100% 100%, 100% 0%
)`
}}
/>
{/* Popover Card */}
<div
className="absolute bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-2xl p-5 w-80 transition-all duration-300 animate-in fade-in zoom-in-95"
style={{
top: coords.top + coords.height > window.innerHeight - 250
? coords.top - 220
: coords.top + coords.height + 16,
left: Math.max(16, Math.min(window.innerWidth - 336, coords.left + (coords.width / 2) - 160))
}}
>
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-zinc-900 dark:text-zinc-100">{step.title}</h3>
<button
onClick={completeTour}
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors"
>
<X className="w-4 h-4 text-zinc-500" />
</button>
</div>
<p className="text-sm text-zinc-600 dark:text-zinc-400 mb-5 leading-relaxed">
{step.body}
</p>
<div className="flex justify-between items-center">
<span className="text-xs text-zinc-400">
Step {currentStep + 1} of {TOUR_STEPS.length}
</span>
<button
onClick={handleNext}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors"
>
{currentStep === TOUR_STEPS.length - 1 ? 'Finish' : 'Next'}
</button>
</div>
</div>
</div>
);
}

183
scripts/phase11_features.py Normal file
View File

@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Phase-11: onboarding-tour, pdf-export, time-entry-csv-import, customer-archive, project-cloning."""
from __future__ import annotations
import asyncio
import datetime
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from phase2_features import Feature, FileGen, ROOT, log, log_section # noqa: E402
from phase3_features import run_feature_v2 # noqa: E402
PHASE11_STATE = ROOT / ".phase11-state.json"
FEATURES: list[Feature] = [
Feature(
name="onboarding-tour",
description="Onboarding-Tour-Component (intro.js-Style overlay)",
files=[
FileGen(
path="apps/web/src/components/OnboardingTour.tsx",
purpose=(
"Onboarding-Tour. Steps-Array mit {selector, title, body} (z.B. Dashboard nav, time-entries link, ⌘K-hint). "
"Erkennt 'onboarding-done' in localStorage; wenn nicht: zeigt overlay mit Spotlight auf target-element + Next/Skip. "
"Tailwind: fixed inset-0 with pointer-events-auto bg-black/40, popover-card."
),
),
FileGen(
path="apps/web/src/App.tsx",
purpose="ERWEITERT — mount <OnboardingTour /> global. Behalte alles.",
refs=["apps/web/src/App.tsx"],
),
],
),
Feature(
name="time-entry-csv-import",
description="TimeEntries-CSV-Import (multipart)",
files=[
FileGen(
path="apps/api/src/routes/time-entries.ts",
purpose=(
"ERWEITERT — behalte alles. Füge POST /import (multipart CSV: description,startTime,endTime,projectId optional). "
"Parse rows, insert für user. Return {imported, errors[]}."
),
refs=["apps/api/src/routes/time-entries.ts"],
),
FileGen(
path="apps/web/src/pages/TimeEntries.tsx",
purpose=(
"ERWEITERT — füge 'Import CSV'-Button rechts im Filter-Bar (neben Export). "
"File-Input → api.importTimeEntriesCsv(file), Toast Ergebnis, refetch."
),
refs=["apps/web/src/pages/TimeEntries.tsx"],
),
],
),
Feature(
name="customer-archive",
description="Soft-archive von Customers (toggle active=false) + Filter",
files=[
FileGen(
path="apps/web/src/pages/Customers.tsx",
purpose=(
"ERWEITERT — füge Archive-Button (statt Delete) pro Customer (PATCH active=false). "
"Filter-Bar: Toggle 'Auch archivierte anzeigen' (default off). "
"Archivierte Rows visually muted (opacity-50). Behalte alles."
),
refs=["apps/web/src/pages/Customers.tsx"],
),
],
),
Feature(
name="project-cloning",
description="Project-Clone Endpoint + UI-Button",
files=[
FileGen(
path="apps/api/src/routes/projects.ts",
purpose=(
"ERWEITERT — behalte alles. Füge POST /:id/clone (body optional: {name}): "
"kopiert existing project, neuer name (default '<orig> (Kopie)'), gleiches customerId. Return new project."
),
refs=["apps/api/src/routes/projects.ts"],
),
FileGen(
path="apps/web/src/pages/Projects.tsx",
purpose=(
"ERWEITERT — füge Clone-Button (Copy-Icon lucide-react) pro Row. "
"Mutation api.cloneProject(id), refetch + toast."
),
refs=["apps/web/src/pages/Projects.tsx"],
),
],
),
Feature(
name="pdf-export-stub",
description="PDF-Export-Endpoint für Reports (Stub — generiert text mit .pdf header)",
files=[
FileGen(
path="apps/api/src/routes/reports.ts",
purpose=(
"Fastify-Plugin /api/reports. Auth required. "
"GET /pdf?from=...&to=... → returnt text/plain stub mit pdf-Header (application/pdf), "
"filename=report-YYYY-MM-DD.pdf. Inhalt: 'EmberClone Report\\n\\nUser: ...\\nPeriod: ... to ...\\nEntries: ...' "
"(echtes PDF-Rendering in v2). Generate from time_entries des Users."
),
refs=["apps/api/src/routes/time-entries.ts"],
),
FileGen(
path="apps/web/src/pages/Dashboard.tsx",
purpose=(
"ERWEITERT — behalte alles. Füge 'Report exportieren' Button (download-icon) oben rechts. "
"Klick: window.open('/api/reports/pdf?from=...&to=...') mit dieser Woche als Default."
),
refs=["apps/web/src/pages/Dashboard.tsx"],
),
],
),
Feature(
name="api-client-phase11",
description="API um phase11 endpoints erweitern",
files=[
FileGen(
path="apps/web/src/lib/api.ts",
purpose=(
"ERWEITERT — behalte ALLES. Füge: importTimeEntriesCsv(file), cloneProject(id, name?), "
"archiveCustomer(id), unarchiveCustomer(id)."
),
refs=["apps/web/src/lib/api.ts"],
),
],
),
Feature(
name="router-phase11",
description="routes/index.ts mount reports",
files=[
FileGen(
path="apps/api/src/routes/index.ts",
purpose="ERWEITERT — füge reportsRoutes ('/api/reports'). Behalte alles.",
refs=["apps/api/src/routes/index.ts"],
),
],
),
]
def load_state() -> dict:
if PHASE11_STATE.exists():
return json.loads(PHASE11_STATE.read_text())
return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()}
def save_state(state: dict) -> None:
PHASE11_STATE.write_text(json.dumps(state, indent=2))
async def main() -> int:
log_section("🚀 Phase-11 Codegen-Run gestartet")
state = load_state()
for feature in FEATURES:
if feature.name in state.get("completed_features", []):
continue
state["current_feature"] = feature.name; save_state(state)
try:
success = await run_feature_v2(feature)
if success:
state.setdefault("completed_features", []).append(feature.name)
else:
state.setdefault("attempted_features", []).append(feature.name)
save_state(state)
except Exception as e:
log(f"{feature.name} crashed: {e}", level="ERROR")
state.setdefault("attempted_features", []).append(feature.name); save_state(state)
log_section("Phase-11 Run beendet")
log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))