feat(admin-user-management): Admin-only CRUD /api/users + Settings-Page für User-Verwaltu [tsc:ok]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 05:12:48 +02:00
parent 1980d6b9ed
commit 6d4213a31c
8 changed files with 826 additions and 5 deletions

5
.phase4-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"completed_features": [],
"current_feature": "admin-user-management",
"started_at": "2026-05-23T05:10:51.482879"
}

View File

@ -455,3 +455,19 @@ undefined
## Phase-3 Run beendet (2026-05-23 05:01:13) ## Phase-3 Run beendet (2026-05-23 05:01:13)
- `05:01:13` **INFO** OK: 4, Attempted: 3, Total: 7 - `05:01:13` **INFO** OK: 4, Attempted: 3, Total: 7
## 🚀 Phase-4 Codegen-Run gestartet (2026-05-23 05:10:47)
- `05:10:47` **INFO** Features: 6
- `05:10:47` **INFO** Ensuring recharts dep…
- `05:10:51` **INFO** recharts install rc=0
## Phase-3 Feature: admin-user-management (2026-05-23 05:10:51)
- `05:10:51` **INFO** Description: Admin-only CRUD /api/users + Settings-Page für User-Verwaltung
- `05:10:51` **INFO** Generating apps/api/src/routes/users.ts (ERWEITERTE users.ts — behalte GET /me + PATCH /me. Füge hinzu (alle nu…)
- `05:11:24` **INFO** wrote 3491 chars in 33.4s (attempt 1)
- `05:11:24` **INFO** Generating apps/web/src/pages/AdminUsers.tsx (Admin-User-Management Page. Liste aller User (TanStack Query). Inline-…)
- `05:12:47` **INFO** wrote 9929 chars in 82.5s (attempt 1)
- `05:12:47` **INFO** Running tsc --noEmit on api…
- `05:12:48` **INFO** tsc clean ✓

View File

@ -3,9 +3,18 @@ import { db } from "../db"
import { users } from "../db/schema" import { users } from "../db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { z } from "zod" import { z } from "zod"
import argon2 from "argon2"
const UserUpdateSchema = z.object({ const UserUpdateSchema = z.object({
name: z.string().min(1).optional() name: z.string().min(1).optional(),
role: z.string().optional()
})
const UserCreateSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
role: z.string(),
password: z.string().min(8)
}) })
export default async function userRoutes(fastify: FastifyInstance) { export default async function userRoutes(fastify: FastifyInstance) {
@ -17,6 +26,10 @@ export default async function userRoutes(fastify: FastifyInstance) {
} }
}) })
const isAdmin = (request: any) => {
return (request.user as { sub: string, role: string })?.role === "admin"
}
fastify.get("/me", async (request, reply) => { fastify.get("/me", async (request, reply) => {
const userId = (request.user as { sub: string } | undefined)?.sub const userId = (request.user as { sub: string } | undefined)?.sub
@ -57,4 +70,73 @@ export default async function userRoutes(fastify: FastifyInstance) {
return user return user
}) })
fastify.get("/", async (request, reply) => {
if (!isAdmin(request)) {
return reply.code(403).send({ message: "Forbidden: Admin role required" })
}
return await db.select().from(users)
})
fastify.post("/", async (request, reply) => {
if (!isAdmin(request)) {
return reply.code(403).send({ message: "Forbidden: Admin role required" })
}
const body = UserCreateSchema.parse(request.body)
const passwordHash = await argon2.hash(body.password)
const [user] = await db
.insert(users)
.values({
email: body.email,
name: body.name,
role: body.role,
passwordHash
})
.returning()
return reply.code(201).send(user)
})
fastify.patch("/:id", async (request, reply) => {
if (!isAdmin(request)) {
return reply.code(403).send({ message: "Forbidden: Admin role required" })
}
const { id } = request.params as { id: string }
const body = UserUpdateSchema.parse(request.body)
const [user] = await db
.update(users)
.set(body)
.where(eq(users.id, id))
.returning()
if (!user) {
return reply.code(404).send({ message: "User not found" })
}
return user
})
fastify.delete("/:id", async (request, reply) => {
if (!isAdmin(request)) {
return reply.code(403).send({ message: "Forbidden: Admin role required" })
}
const { id } = request.params as { id: string }
const result = await db
.delete(users)
.where(eq(users.id, id))
.returning()
if (result.length === 0) {
return reply.code(404).send({ message: "User not found" })
}
return reply.code(204).send()
})
} }

View File

@ -14,7 +14,8 @@
"@tanstack/react-router": "^1.62.7", "@tanstack/react-router": "^1.62.7",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.10", "@types/react": "^18.3.10",

View File

@ -0,0 +1,244 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { api } from "../lib/api"
import type { User } from "@emberclone/shared"
export default function AdminUsers() {
const queryClient = useQueryClient()
const [me, setMe] = useState<{ role: string } | null>(null)
const [isMeLoading, setIsMeLoading] = useState(true)
const [newUser, setNewUser] = useState({ email: "", name: "", role: "user", password: "" })
const [editingUser, setEditingUser] = useState<User | null>(null)
// Check if current user is admin
const { data: currentUser } = useQuery({
queryKey: ["me"],
queryFn: () => api.getMe(),
onSuccess: (data) => {
setMe(data)
setIsMeLoading(false)
}
})
const { data: users, isLoading, isError } = useQuery({
queryKey: ["users"],
queryFn: () => api.listUsers(),
enabled: !!currentUser && currentUser.role === "admin"
})
const createMutation = useMutation({
mutationFn: (data: any) => api.createUser(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] })
setNewUser({ email: "", name: "", role: "user", password: "" })
}
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => api.updateUser(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] })
setEditingUser(null)
}
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] })
}
})
if (isMeLoading || !currentUser) {
return <div className="p-6 text-gray-500">Loading permissions...</div>
}
if (currentUser.role !== "admin") {
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-md">
<h2 className="font-bold">Forbidden</h2>
<p>You do not have administrative privileges to access this page.</p>
</div>
</div>
)
}
const handleCreateSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!newUser.email || !newUser.password) return
createMutation.mutate(newUser)
}
const handleUpdateSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!editingUser) return
updateMutation.mutate({
id: editingUser.id,
data: { name: editingUser.name, role: editingUser.role }
})
}
if (isLoading) return <div className="p-6 text-gray-500">Loading users...</div>
if (isError) return <div className="p-6 text-red-500">Error loading users.</div>
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<header>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-500">Manage system users and access levels</p>
</header>
<section className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Create New User</h2>
<form onSubmit={handleCreateSubmit} className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
value={newUser.name}
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none bg-white"
value={newUser.role}
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="flex gap-2">
<input
type="password"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
/>
<button
type="submit"
disabled={createMutation.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 font-medium"
>
{createMutation.isPending ? "..." : "Add"}
</button>
</div>
</div>
</form>
</section>
<section className="overflow-x-auto bg-white rounded-lg border border-gray-200 shadow-sm">
<table className="w-full text-left border-collapse">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-sm font-semibold text-gray-600">User</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-600">Role</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-600 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{users && users.map((user: User) => (
<tr key={user.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">{user.name}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-700'
}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 text-right space-x-3">
<button
onClick={() => setEditingUser(user)}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Edit
</button>
<button
onClick={() => {
if (confirm(`Delete user ${user.email}?`)) {
deleteMutation.mutate(user.id)
}
}}
disabled={deleteMutation.isPending}
className="text-red-600 hover:text-red-800 text-sm font-medium disabled:text-gray-400"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
{editingUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6 shadow-xl">
<h2 className="text-xl font-bold mb-4">Edit User</h2>
<form onSubmit={handleUpdateSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
value={editingUser.name}
onChange={(e) => setEditingUser({ ...editingUser, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none bg-white"
value={editingUser.role}
onChange={(e) => setEditingUser({ ...editingUser, role: e.target.value as any })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={() => setEditingUser(null)}
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 text-sm font-medium"
>
{updateMutation.isPending ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

271
pnpm-lock.yaml generated
View File

@ -81,6 +81,9 @@ importers:
react-dom: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@18.3.29)(react-dom@18.3.1)(react-is@19.2.6)(react@18.3.1)(redux@5.0.1)
devDependencies: devDependencies:
'@types/react': '@types/react':
specifier: ^18.3.10 specifier: ^18.3.10
@ -1362,6 +1365,27 @@ packages:
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
dev: false dev: false
/@reduxjs/toolkit@2.12.0(react-redux@9.3.0)(react@18.3.1):
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.8
react: 18.3.1
react-redux: 9.3.0(@types/react@18.3.29)(react@18.3.1)(redux@5.0.1)
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
dev: false
/@rolldown/pluginutils@1.0.0-beta.27: /@rolldown/pluginutils@1.0.0-beta.27:
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
dev: true dev: true
@ -1566,6 +1590,14 @@ packages:
dev: true dev: true
optional: true optional: true
/@standard-schema/spec@1.1.0:
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
dev: false
/@standard-schema/utils@0.3.0:
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
dev: false
/@tanstack/history@1.162.0: /@tanstack/history@1.162.0:
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
@ -1654,6 +1686,48 @@ packages:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
dev: true dev: true
/@types/d3-array@3.2.2:
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
dev: false
/@types/d3-color@3.1.3:
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
dev: false
/@types/d3-ease@3.0.2:
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
dev: false
/@types/d3-interpolate@3.0.4:
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
dependencies:
'@types/d3-color': 3.1.3
dev: false
/@types/d3-path@3.1.1:
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
dev: false
/@types/d3-scale@4.0.9:
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
dependencies:
'@types/d3-time': 3.0.4
dev: false
/@types/d3-shape@3.1.8:
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
dependencies:
'@types/d3-path': 3.1.1
dev: false
/@types/d3-time@3.0.4:
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
dev: false
/@types/d3-timer@3.0.2:
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
dev: false
/@types/estree@1.0.8: /@types/estree@1.0.8:
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
dev: true dev: true
@ -1672,7 +1746,6 @@ packages:
/@types/prop-types@15.7.15: /@types/prop-types@15.7.15:
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
dev: true
/@types/react-dom@18.3.7(@types/react@18.3.29): /@types/react-dom@18.3.7(@types/react@18.3.29):
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
@ -1687,7 +1760,10 @@ packages:
dependencies: dependencies:
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
csstype: 3.2.3 csstype: 3.2.3
dev: true
/@types/use-sync-external-store@0.0.6:
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
dev: false
/@vitejs/plugin-react@4.7.0(vite@5.4.21): /@vitejs/plugin-react@4.7.0(vite@5.4.21):
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
@ -1894,6 +1970,11 @@ packages:
wrap-ansi: 7.0.0 wrap-ansi: 7.0.0
dev: true dev: true
/clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
dev: false
/color-convert@2.0.1: /color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@ -1949,7 +2030,77 @@ packages:
/csstype@3.2.3: /csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
dev: true
/d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
dependencies:
internmap: 2.0.3
dev: false
/d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
dev: false
/d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
dev: false
/d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
dev: false
/d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
dependencies:
d3-color: 3.1.0
dev: false
/d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
dev: false
/d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
dev: false
/d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
dependencies:
d3-path: 3.1.0
dev: false
/d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
dependencies:
d3-time: 3.1.0
dev: false
/d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
dependencies:
d3-array: 3.2.4
dev: false
/d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
dev: false
/debug@4.4.3: /debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
@ -1963,6 +2114,10 @@ packages:
ms: 2.1.3 ms: 2.1.3
dev: true dev: true
/decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
dev: false
/didyoumean@1.2.2: /didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true dev: true
@ -2098,6 +2253,10 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
dev: true dev: true
/es-toolkit@1.46.1:
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
dev: false
/esbuild-register@3.6.0(esbuild@0.19.12): /esbuild-register@3.6.0(esbuild@0.19.12):
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies: peerDependencies:
@ -2240,6 +2399,10 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true dev: true
/eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
dev: false
/fast-content-type-parse@1.1.0: /fast-content-type-parse@1.1.0:
resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
dev: false dev: false
@ -2441,10 +2604,23 @@ packages:
function-bind: 1.1.2 function-bind: 1.1.2
dev: true dev: true
/immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
dev: false
/immer@11.1.8:
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
dev: false
/inherits@2.0.4: /inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: false dev: false
/internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
dev: false
/ipaddr.js@1.9.1: /ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -2887,6 +3063,29 @@ packages:
scheduler: 0.23.2 scheduler: 0.23.2
dev: false dev: false
/react-is@19.2.6:
resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==}
dev: false
/react-redux@9.3.0(@types/react@18.3.29)(react@18.3.1)(redux@5.0.1):
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
dependencies:
'@types/react': 18.3.29
'@types/use-sync-external-store': 0.0.6
react: 18.3.1
redux: 5.0.1
use-sync-external-store: 1.6.0(react@18.3.1)
dev: false
/react-refresh@0.17.0: /react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2917,6 +3116,45 @@ packages:
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
dev: false dev: false
/recharts@3.8.1(@types/react@18.3.29)(react-dom@18.3.1)(react-is@19.2.6)(react@18.3.1)(redux@5.0.1):
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0)(react@18.3.1)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.46.1
eventemitter3: 5.0.4
immer: 10.2.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is: 19.2.6
react-redux: 9.3.0(@types/react@18.3.29)(react@18.3.1)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@18.3.1)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
dev: false
/redux-thunk@3.1.0(redux@5.0.1):
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
dependencies:
redux: 5.0.1
dev: false
/redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
dev: false
/require-directory@2.1.1: /require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2927,6 +3165,10 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: false dev: false
/reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
dev: false
/resolve-pkg-maps@1.0.0: /resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
dev: true dev: true
@ -3203,6 +3445,10 @@ packages:
real-require: 0.2.0 real-require: 0.2.0
dev: false dev: false
/tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
dev: false
/tinyglobby@0.2.16: /tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -3278,6 +3524,25 @@ packages:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true dev: true
/victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
dev: false
/vite@5.4.21: /vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}

Binary file not shown.

208
scripts/phase4_features.py Normal file
View File

@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Phase-4: admin-page, CSV export, mobile-fix, error-boundary, dashboard-charts."""
from __future__ import annotations
import asyncio
import datetime
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from phase2_features import ( # noqa: E402
Feature, FileGen, ROOT, log, log_section, git, gemma,
strip_codefence, load_existing, tsc_check, MAX_RETRIES,
)
from phase3_features import HARDENING_HINTS, generate_with_hints, run_feature_v2 # noqa: E402
PHASE4_STATE = ROOT / ".phase4-state.json"
FEATURES: list[Feature] = [
Feature(
name="admin-user-management",
description="Admin-only CRUD /api/users + Settings-Page für User-Verwaltung",
files=[
FileGen(
path="apps/api/src/routes/users.ts",
purpose=(
"ERWEITERTE users.ts — behalte GET /me + PATCH /me. "
"Füge hinzu (alle nur für role='admin'): "
"GET / (list all users), POST / (create with email+name+role+password — argon2-hash), "
"PATCH /:id (update name, role), DELETE /:id (cascade time-entries). "
"Verwende `request.user as { sub: string, role: string }` für payload-access. "
"403 wenn role !== 'admin'."
),
refs=["apps/api/src/routes/users.ts", "apps/api/src/db/schema.ts"],
),
FileGen(
path="apps/web/src/pages/AdminUsers.tsx",
purpose=(
"Admin-User-Management Page. Liste aller User (TanStack Query). "
"Inline-Form zum Anlegen (email, name, role-select, password). "
"Edit-Button pro Row → Modal mit name/role-Update. Delete-Button mit confirm. "
"Verwende api.listUsers(), api.createUser(), api.updateUser(), api.deleteUser(). "
"Wenn current user role !== 'admin': zeige Forbidden-Message statt Inhalt."
),
refs=["apps/web/src/pages/Customers.tsx", "apps/web/src/lib/api.ts"],
),
],
),
Feature(
name="csv-export-time-entries",
description="CSV-Export-Endpoint + Button in TimeEntries-Page",
files=[
FileGen(
path="apps/api/src/routes/time-entries.ts",
purpose=(
"ERWEITERTE time-entries.ts — behalte alle bestehenden Routes (CRUD + running/start/stop). "
"Füge hinzu: GET /export.csv?from=...&to=... → returnt text/csv content mit Spalten "
"id,description,projectId,startTime,endTime,durationMinutes. "
"Header content-type: text/csv, content-disposition: attachment; filename=time-entries.csv. "
"Auth erforderlich, user sieht nur eigene (außer admin)."
),
refs=["apps/api/src/routes/time-entries.ts"],
),
FileGen(
path="apps/web/src/pages/TimeEntries.tsx",
purpose=(
"ERWEITERT — behalte Form + Filter + Liste. Füge Export-Button im Filter-Bar: "
"ruft `window.location.href = \\'/api/time-entries/export.csv?from=...&to=...\\'` "
"mit aktuellen Filter-Werten. Stil: outline-Button rechts neben To-Date."
),
refs=["apps/web/src/pages/TimeEntries.tsx"],
),
],
),
Feature(
name="error-boundary",
description="React ErrorBoundary + global wrapping in App.tsx",
files=[
FileGen(
path="apps/web/src/components/ErrorBoundary.tsx",
purpose=(
"React-ErrorBoundary class-component. Fängt unkaufgefangene Render-Errors. "
"Zeigt schöne Fehlerseite mit message + reload-button. "
"Verwende componentDidCatch + getDerivedStateFromError. "
"Tailwind, centered, max-w-md."
),
),
FileGen(
path="apps/web/src/App.tsx",
purpose=(
"ERWEITERT — wrap RouterProvider in <ErrorBoundary>. Behalte ToastProvider + alle Routes. "
"Importiere ErrorBoundary von ./components/ErrorBoundary."
),
refs=["apps/web/src/App.tsx"],
),
],
),
Feature(
name="dashboard-charts",
description="Dashboard mit Stunden-Chart (recharts)",
files=[
FileGen(
path="apps/web/src/pages/Dashboard.tsx",
purpose=(
"ÜBERARBEITETER Dashboard. Behalte die 3 Karten (Heute/Woche/Aktive Projekte). "
"Füge unten ein BarChart hinzu (recharts BarChart + Bar) mit den letzten 7 Tagen — "
"x-axis: dayName, y-axis: hours. Daten aus listTimeEntries letzte 7 Tage, "
"group-by-day per useMemo. Card-Wrapper mit Tailwind."
),
refs=["apps/web/src/pages/Dashboard.tsx"],
),
],
),
Feature(
name="api-client-phase4",
description="API-Client um Admin-User + Export-URL ergänzt",
files=[
FileGen(
path="apps/web/src/lib/api.ts",
purpose=(
"FINAL+ - behalte ALLES aus vorher. Füge hinzu: "
"listUsers(), createUser({email,name,role,password}), updateUser(id, {name,role}), deleteUser(id), "
"Alle Admin-Endpoints → /api/users."
),
refs=["apps/web/src/lib/api.ts"],
),
],
),
Feature(
name="router-with-admin",
description="App.tsx +/admin route + Nav admin-link bei admin-role",
files=[
FileGen(
path="apps/web/src/App.tsx",
purpose=(
"ERWEITERT — füge Route /admin (AdminUsers component) hinzu. Auth-Check: zusätzlich role='admin' (sonst redirect /). "
"Behalte ErrorBoundary + ToastProvider + alle Routes."
),
refs=["apps/web/src/App.tsx", "apps/web/src/pages/AdminUsers.tsx"],
),
FileGen(
path="apps/web/src/components/Nav.tsx",
purpose=(
"ERWEITERT — Nav zeigt Admin-Link nur wenn current user role='admin'. "
"useQuery(['me'], api.getMe) → wenn data.role==='admin', render <Link to='/admin'>Admin</Link>. "
"Behalte alle anderen Links + Logout."
),
refs=["apps/web/src/components/Nav.tsx"],
),
],
),
]
def load_state() -> dict:
if PHASE4_STATE.exists():
return json.loads(PHASE4_STATE.read_text())
return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()}
def save_state(state: dict) -> None:
PHASE4_STATE.write_text(json.dumps(state, indent=2))
async def main() -> int:
log_section(f"🚀 Phase-4 Codegen-Run gestartet")
log(f"Features: {len(FEATURES)}")
# ensure recharts is installed for dashboard-charts
log("Ensuring recharts dep…")
import subprocess
r = subprocess.run(["pnpm", "--filter", "web", "add", "recharts"],
cwd=ROOT, capture_output=True, text=True, timeout=120)
log(f" recharts install rc={r.returncode}")
state = load_state()
for feature in FEATURES:
if feature.name in state.get("completed_features", []):
log(f"⏭ Skip {feature.name}")
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 {feature.name} crashed: {e}", level="ERROR")
state.setdefault("attempted_features", []).append(feature.name)
save_state(state)
log_section("Phase-4 Run beendet")
log(f"OK: {len(state.get('completed_features', []))}, "
f"Attempted: {len(state.get('attempted_features', []))}, "
f"Total: {len(FEATURES)}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))