EmberClone/scripts/phase4_features.py

209 lines
8.5 KiB
Python

#!/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()))