feat(admin-user-management): Admin-only CRUD /api/users + Settings-Page für User-Verwaltu [tsc:ok]
This commit is contained in:
parent
1980d6b9ed
commit
6d4213a31c
5
.phase4-state.json
Normal file
5
.phase4-state.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"completed_features": [],
|
||||
"current_feature": "admin-user-management",
|
||||
"started_at": "2026-05-23T05:10:51.482879"
|
||||
}
|
||||
@ -455,3 +455,19 @@ undefined
|
||||
## Phase-3 Run beendet (2026-05-23 05:01:13)
|
||||
|
||||
- `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 ✓
|
||||
|
||||
@ -3,9 +3,18 @@ import { db } from "../db"
|
||||
import { users } from "../db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
import argon2 from "argon2"
|
||||
|
||||
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) {
|
||||
@ -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) => {
|
||||
const userId = (request.user as { sub: string } | undefined)?.sub
|
||||
|
||||
@ -57,4 +70,73 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
@ -14,7 +14,8 @@
|
||||
"@tanstack/react-router": "^1.62.7",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.10",
|
||||
|
||||
244
apps/web/src/pages/AdminUsers.tsx
Normal file
244
apps/web/src/pages/AdminUsers.tsx
Normal 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
271
pnpm-lock.yaml
generated
@ -81,6 +81,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^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:
|
||||
'@types/react':
|
||||
specifier: ^18.3.10
|
||||
@ -1362,6 +1365,27 @@ packages:
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
dev: true
|
||||
@ -1566,6 +1590,14 @@ packages:
|
||||
dev: 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:
|
||||
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
|
||||
engines: {node: '>=20.19'}
|
||||
@ -1654,6 +1686,48 @@ packages:
|
||||
'@babel/types': 7.29.0
|
||||
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:
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
dev: true
|
||||
@ -1672,7 +1746,6 @@ packages:
|
||||
|
||||
/@types/prop-types@15.7.15:
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
dev: true
|
||||
|
||||
/@types/react-dom@18.3.7(@types/react@18.3.29):
|
||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||
@ -1687,7 +1760,10 @@ packages:
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.15
|
||||
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):
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
@ -1894,6 +1970,11 @@ packages:
|
||||
wrap-ansi: 7.0.0
|
||||
dev: true
|
||||
|
||||
/clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@ -1949,7 +2030,77 @@ packages:
|
||||
|
||||
/csstype@3.2.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
@ -1963,6 +2114,10 @@ packages:
|
||||
ms: 2.1.3
|
||||
dev: true
|
||||
|
||||
/decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
dev: false
|
||||
|
||||
/didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
dev: true
|
||||
@ -2098,6 +2253,10 @@ packages:
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: true
|
||||
|
||||
/es-toolkit@1.46.1:
|
||||
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
|
||||
dev: false
|
||||
|
||||
/esbuild-register@3.6.0(esbuild@0.19.12):
|
||||
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
||||
peerDependencies:
|
||||
@ -2240,6 +2399,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
dev: false
|
||||
|
||||
/fast-content-type-parse@1.1.0:
|
||||
resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
|
||||
dev: false
|
||||
@ -2441,10 +2604,23 @@ packages:
|
||||
function-bind: 1.1.2
|
||||
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:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@ -2887,6 +3063,29 @@ packages:
|
||||
scheduler: 0.23.2
|
||||
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:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -2917,6 +3116,45 @@ packages:
|
||||
engines: {node: '>= 12.13.0'}
|
||||
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:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -2927,6 +3165,10 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
dev: false
|
||||
|
||||
/resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
dev: true
|
||||
@ -3203,6 +3445,10 @@ packages:
|
||||
real-require: 0.2.0
|
||||
dev: false
|
||||
|
||||
/tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
dev: false
|
||||
|
||||
/tinyglobby@0.2.16:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@ -3278,6 +3524,25 @@ packages:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
||||
BIN
scripts/__pycache__/phase3_features.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/phase3_features.cpython-312.pyc
Normal file
Binary file not shown.
208
scripts/phase4_features.py
Normal file
208
scripts/phase4_features.py
Normal 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()))
|
||||
Loading…
Reference in New Issue
Block a user