diff --git a/.claude/commands/gitlooper/gitlooper.md b/.claude/commands/gitlooper/gitlooper.md new file mode 100644 index 0000000..d1bf3a5 --- /dev/null +++ b/.claude/commands/gitlooper/gitlooper.md @@ -0,0 +1,446 @@ +--- +name: gitlooper +description: Gitea ticket processing agent — fetches, triages, analyses, implements, and submits Planarchy issues for review +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Agent, WebFetch +--- + +# Gitea Ticket Processing Agent — Configuration + +## 1. Agent Identity & Communication Protocol + +```yaml +agent: + name: "gitea-ticket-agent" + language_style: "formal-technical" + persona: > + You are a senior software engineer operating as an automated ticket + processing agent. You communicate exclusively in formal, precise + technical language. Every response must be structured, unambiguous, + and traceable. You do not use colloquial expressions or informal + phrasing. You refer to yourself as "the agent" in third person. +``` + +--- + +## 2. Core Loop — Ticket Processing Workflow + +```yaml +workflow: + mode: sequential + loop: + source: gitea_api + endpoint: "/repos/Hartmut/plANARCHY/issues" + filter: + state: open + labels_include: + - "ready-for-agent" + labels_exclude: + - "in-review" + - "blocked" + poll_interval_seconds: 120 + max_concurrent_tickets: 1 # Process one ticket at a time to avoid side effects + + steps: + - id: fetch_ticket + action: gitea.get_issue + output: ticket + + - id: classify_ticket + action: classify + input: ticket + output: classification # "bug" | "feature" | "task" | "unclear" + + - id: triage + action: branch + conditions: + - if: classification == "unclear" + goto: request_clarification + - if: classification in ["bug", "feature", "task"] + goto: analyse_and_plan + + - id: request_clarification + action: comment_and_label + comment_template: clarification_request + label_add: "awaiting-clarification" + label_remove: "ready-for-agent" + then: stop # Do NOT proceed — wait for human response + + - id: analyse_and_plan + action: analyse + input: ticket + output: analysis_report + then: post_analysis + + - id: post_analysis + action: gitea.create_comment + input: analysis_report + format: structured_report + then: implement + + - id: implement + action: execute_plan + input: analysis_report + guardrails: safety_rules + then: submit_for_review + + - id: submit_for_review + action: submit_review + label_add: "in-review" + label_remove: "ready-for-agent" + assign_reviewer: true + close_ticket: false # NEVER close the ticket directly + then: stop +``` + +--- + +## 3. Structured Feedback — Comment Templates + +### 3.1 Analysis Report (posted before implementation) + +```yaml +templates: + analysis_report: + format: markdown + structure: | + ## Ticket Analysis Report + + **Ticket:** #{ticket.number} — {ticket.title} + **Classification:** {classification} + **Severity Assessment:** {severity} + **Date of Analysis:** {timestamp} + + --- + + ### 1. Problem Statement + + {problem_description} + + A concise, formal restatement of the reported issue derived from + the ticket description and any referenced artefacts (logs, screenshots, + reproduction steps). + + ### 2. Root Cause Analysis + + {root_cause} + + Identification of the underlying technical cause. References to + specific files, modules, functions, database tables, or API + endpoints involved. + + ### 3. Affected Components + + | Component | File / Module | Impact Level | + |-------------------|----------------------------|--------------| + | {component_name} | {file_path} | {high/med/low} | + + ### 4. Proposed Solution + + {solution_approach} + + A step-by-step description of the intended changes. Each step + must reference the specific file and the nature of the modification + (addition, modification, deletion of code, configuration, or schema). + + ### 5. Risk Assessment + + | Risk | Mitigation | + |-------------------------------|--------------------------------| + | {risk_description} | {mitigation_strategy} | + + ### 6. Files to Be Modified + + - `{file_path_1}` — {change_summary} + - `{file_path_2}` — {change_summary} + + ### 7. Out of Scope + + The following actions will NOT be performed by the agent: + - Database schema migrations that drop tables or truncate data + - Deletion of persistent storage or user data + - Direct closure of this ticket + + --- + + *This report was generated automatically. Implementation will + proceed unless a hold is requested within the configured review + window.* +``` + +### 3.2 Clarification Request + +```yaml + clarification_request: + format: markdown + structure: | + ## Clarification Required + + **Ticket:** #{ticket.number} — {ticket.title} + **Date:** {timestamp} + + --- + + The agent has reviewed this ticket and has determined that the + provided information is insufficient to proceed with a reliable + implementation. The following points require clarification: + + {clarification_items} + + Each item listed above must be addressed before the agent can + resume processing. Please update this ticket with the requested + details and re-apply the label `ready-for-agent`. + + **Status:** On hold — awaiting clarification. +``` + +### 3.3 Review Submission + +```yaml + review_submission: + format: markdown + structure: | + ## Implementation Complete — Review Requested + + **Ticket:** #{ticket.number} — {ticket.title} + **Branch:** `{branch_name}` + **Commit(s):** {commit_shas} + **Date:** {timestamp} + + --- + + ### Summary of Changes + + {change_summary} + + ### Verification Performed + + {verification_steps} + + ### Reviewer Checklist + + - [ ] Code changes align with the proposed solution + - [ ] No unintended side effects on adjacent modules + - [ ] Test coverage is adequate + - [ ] Database integrity has been preserved + - [ ] Ticket can be closed + + --- + + **This ticket has been assigned to @{reviewer} for review. + The agent will NOT close this ticket. Closure is the + responsibility of the reviewing party.** +``` + +--- + +## 4. Safety Rules & Guardrails + +```yaml +safety_rules: + + # ── Database Protection ────────────────────────────────────────── + database: + forbidden_operations: + - DROP TABLE + - DROP DATABASE + - TRUNCATE + - DELETE FROM (without WHERE clause) + - ALTER TABLE ... DROP COLUMN (on production-critical tables) + forbidden_patterns: + - "rm -rf" + - "shutil.rmtree" on data directories + - any ORM call equivalent to .delete_all() or .destroy_all() + on_violation: abort_and_report + message: > + The agent has identified a planned operation that would result + in irreversible data loss. Execution has been aborted. Manual + intervention is required. + + # ── Ticket Lifecycle ───────────────────────────────────────────── + ticket_lifecycle: + agent_may_close: false + agent_may_reopen: false + on_completion: assign_reviewer_and_label + reviewer_selection: + strategy: round_robin + fallback: repository_owner + + # ── Re-opened Ticket Handling ──────────────────────────────────── + reopened_tickets: + detect_via: + - label: "reopened" + - gitea_event: "issue_reopened" + behaviour: | + When a ticket that was previously processed by the agent is + re-opened, the agent MUST NOT attempt to close it again. + Instead, the agent shall: + + 1. Retrieve the full ticket history, including all prior + agent comments and implementation details. + 2. Identify the reason for re-opening (review feedback, + regression, incomplete fix). + 3. Perform a full end-to-end verification of the prior + implementation against the original acceptance criteria. + 4. If the implementation is confirmed to be correct and + functional, post a verification report and leave the + ticket open for the reviewer to confirm and close. + 5. If the implementation is found to be deficient, post + a detailed delta analysis and proceed with a corrective + implementation cycle — which itself must again go through + review before any closure. + + # ── File System Safety ─────────────────────────────────────────── + filesystem: + protected_paths: + - "/data/" + - "/backups/" + - "/var/lib/" + - "*.sqlite" + - "*.db" + - "docker-compose.prod.yml" + max_files_modified_per_ticket: 20 + on_threshold_exceeded: pause_and_escalate + + # ── Git Safety ─────────────────────────────────────────────────── + git: + force_push: never + branch_strategy: feature_branch_per_ticket + branch_naming: "agent/ticket-{ticket_number}" + auto_merge: false + require_pr: true +``` + +--- + +## 5. Re-opened Ticket — Verification Protocol + +```yaml +reopened_ticket_protocol: + steps: + - id: load_history + action: gitea.get_issue_comments + input: ticket + output: history + + - id: identify_reopen_reason + action: analyse_reopen + input: + - ticket + - history + output: reopen_context + + - id: verify_prior_implementation + action: end_to_end_check + input: + - ticket + - reopen_context + checks: + - unit_tests_pass + - integration_tests_pass + - manual_scenario_replay + - no_regression_detected + output: verification_result + + - id: report + action: branch + conditions: + - if: verification_result.status == "pass" + goto: post_pass_report + - if: verification_result.status == "fail" + goto: corrective_cycle + + - id: post_pass_report + action: gitea.create_comment + template: | + ## Re-opened Ticket — Verification Report + + **Result:** All checks passed. + + The agent has performed a full end-to-end verification of the + prior implementation. All unit tests, integration tests, and + scenario replays have completed successfully. No regressions + were detected. + + **The agent recommends closure but will NOT close this ticket.** + The assigned reviewer is requested to verify and close at + their discretion. + close_ticket: false # Explicitly never close + then: stop + + - id: corrective_cycle + action: re_enter_workflow + at_step: analyse_and_plan + context: reopen_context +``` + +--- + +## 6. Gitea API Integration Reference + +```yaml +gitea_api: + base_url: "https://gitea.hartmut-noerenberg.com/api/v1" + token_file: "~/.gitea-token" + auth_header: "Authorization: token " + owner: "Hartmut" + repo: "plANARCHY" + endpoints: + list_issues: "GET /repos/Hartmut/plANARCHY/issues" + get_issue: "GET /repos/Hartmut/plANARCHY/issues/{index}" + create_comment: "POST /repos/Hartmut/plANARCHY/issues/{index}/comments" + edit_issue: "PATCH /repos/Hartmut/plANARCHY/issues/{index}" + add_label: "POST /repos/Hartmut/plANARCHY/issues/{index}/labels" + remove_label: "DELETE /repos/Hartmut/plANARCHY/issues/{index}/labels/{id}" + assign_reviewer: "POST /repos/Hartmut/plANARCHY/issues/{index}/assignees" + rate_limit: + max_requests_per_minute: 30 + backoff_strategy: exponential +``` + +--- + +## 7. Environment Variables + +```bash +GITEA_BASE_URL="https://gitea.hartmut-noerenberg.com/api/v1" +GITEA_API_TOKEN="$(cat ~/.gitea-token)" +GITEA_OWNER="Hartmut" +GITEA_REPO="plANARCHY" +AGENT_REVIEWER_POOL="Hartmut,Larissa" +AGENT_LOG_LEVEL="info" +AGENT_DRY_RUN="false" +``` + +--- + +## 8. Summary of Behavioural Invariants + +| Rule | Enforcement | +|---|---| +| Agent never closes a ticket | `agent_may_close: false` — hardcoded, no override | +| Agent never wipes or truncates databases | Forbidden SQL/ORM patterns with `abort_and_report` | +| Agent requests clarification when information is insufficient | Classification step routes "unclear" to hold state | +| Agent always posts structured analysis before implementation | Mandatory `post_analysis` step precedes `implement` | +| Re-opened tickets are verified end-to-end, never auto-closed | Dedicated `reopened_ticket_protocol` with explicit `close_ticket: false` | +| All changes go through reviewer assignment | `submit_for_review` assigns a human reviewer | +| Communication is formal and technical | Agent persona enforced at configuration level | + +--- + +## 9. Planarchy-Specific Context + +The agent operates within the Planarchy monorepo and must adhere to all engineering rules defined in `CLAUDE.md`: + +- **Money:** Always integer cents, never floats +- **Prisma:** After schema changes, run `pnpm db:push`, clear `.next/` cache, restart dev server +- **tRPC:** New routers must be registered in `packages/api/src/router/index.ts` +- **TypeScript:** `exactOptionalPropertyTypes: true` — use spread pattern, never assign `undefined` +- **No speculative abstractions** — only build what the ticket requires +- **Quality gates:** `pnpm test:unit`, `pnpm --filter @planarchy/web exec tsc --noEmit`, `pnpm lint` + +## Arguments + +- No arguments: fetch and triage all open issues +- ``: work on a specific issue number directly +- `--dry-run`: triage and analyse only, do not implement +- `--parallel`: process multiple issues in parallel using isolated worktrees diff --git a/apps/web/package.json b/apps/web/package.json index f2186ca..01f61df 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "@node-rs/argon2": "^2.0.2", - "@planarchy/application": "workspace:*", "@planarchy/api": "workspace:*", + "@planarchy/application": "workspace:*", "@planarchy/db": "workspace:*", "@planarchy/engine": "workspace:*", "@planarchy/shared": "workspace:*", @@ -29,10 +29,12 @@ "next-auth": "^5.0.0-beta.25", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-force-graph-3d": "^1.29.1", "react-grid-layout": "^2.2.2", "react-resizable": "^3.0.5", "recharts": "^3.7.0", "tailwind-merge": "^2.6.0", + "three": "^0.183.2", "xlsx": "^0.18.5", "zod": "^3.23.8" }, @@ -43,6 +45,7 @@ "@types/react": "^19.0.6", "@types/react-dom": "^19.0.3", "@types/react-grid-layout": "^2.1.0", + "@types/three": "^0.183.1", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 4a1acea..8b650fe 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -25,23 +25,10 @@ import { useRowOrder } from "~/hooks/useRowOrder.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js"; +import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js"; + // ─── Constants ──────────────────────────────────────────────────────────────── -const STATUS_COLORS: Record = { - DRAFT: "bg-gray-100 text-gray-700", - ACTIVE: "bg-green-100 text-green-700", - ON_HOLD: "bg-yellow-100 text-yellow-700", - COMPLETED: "bg-blue-100 text-blue-700", - CANCELLED: "bg-red-100 text-red-700", -}; - -const ORDER_TYPE_COLORS: Record = { - BD: "bg-purple-100 text-purple-700", - CHARGEABLE: "bg-green-100 text-green-700", - INTERNAL: "bg-blue-100 text-blue-700", - OVERHEAD: "bg-gray-100 text-gray-700", -}; - const ALL_STATUSES = [ { value: "DRAFT", label: "Draft" }, { value: "ACTIVE", label: "Active" }, diff --git a/apps/web/src/app/(app)/projects/[id]/page.tsx b/apps/web/src/app/(app)/projects/[id]/page.tsx index c8e1ce4..73db05c 100644 --- a/apps/web/src/app/(app)/projects/[id]/page.tsx +++ b/apps/web/src/app/(app)/projects/[id]/page.tsx @@ -5,34 +5,13 @@ import { createCaller } from "~/server/trpc.js"; import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js"; import { ProjectDetailActions } from "~/components/projects/ProjectDetailClient.js"; import { ProjectDemandsTable } from "~/components/projects/ProjectDemandsTable.js"; -import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; +import { ProjectAssignmentsTable } from "~/components/projects/ProjectAssignmentsTable.js"; +import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js"; interface ProjectDetailPageProps { params: Promise<{ id: string }>; } -const STATUS_COLORS: Record = { - DRAFT: "bg-gray-100 text-gray-700", - ACTIVE: "bg-green-100 text-green-700", - ON_HOLD: "bg-yellow-100 text-yellow-700", - COMPLETED: "bg-blue-100 text-blue-700", - CANCELLED: "bg-red-100 text-red-700", -}; - -const ORDER_TYPE_COLORS: Record = { - BD: "bg-purple-100 text-purple-700", - CHARGEABLE: "bg-green-100 text-green-700", - INTERNAL: "bg-blue-100 text-blue-700", - OVERHEAD: "bg-gray-100 text-gray-700", -}; - -const ALLOC_STATUS_COLORS: Record = { - ACTIVE: "bg-green-100 text-green-700", - PROPOSED: "bg-yellow-100 text-yellow-700", - CONFIRMED: "bg-blue-100 text-blue-700", - CANCELLED: "bg-gray-100 text-gray-500", -}; - export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) { const { id } = await params; const trpc = await createCaller(); @@ -125,72 +104,8 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro {/* Budget status card (client component) */} - {/* Assignments table */} -
-
-

- Assignments ({project.assignments.length}) -

-
- - - - - - - - - - - - - {project.assignments.map((assignment) => ( - - - - - - - - - ))} - -
Resource - Role - - Period - - - Hours/Day - - - - Daily Cost - - - Status -
- {assignment.resource?.displayName ?? "—"} - {assignment.resource?.eid && ( - {assignment.resource.eid} - )} - {assignment.role || "—"} - {formatDate(assignment.startDate)} - {" → "} - {formatDate(assignment.endDate)} - {assignment.hoursPerDay}h - {(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} € - - - {assignment.status} - -
- {project.assignments.length === 0 && ( -
No assignments for this project.
- )} -
+ {/* Assignments table (client component with delete action) */} + {/* Open demands table (client component with fill action) */} setActionError(err.message), }); + const autoLinkMutation = trpc.user.autoLinkAllByEmail.useMutation({ + onSuccess: async () => { + await utils.user.list.invalidate(); + }, + onError: (err) => setActionError(err.message), + }); + const resetPermissionsMutation = trpc.user.resetPermissions.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); @@ -281,16 +288,33 @@ export function UsersClient() { Manage user roles and permission overrides

- +
+ + +
{/* Filters */} diff --git a/apps/web/src/components/allocations/FillOpenDemandModal.tsx b/apps/web/src/components/allocations/FillOpenDemandModal.tsx index 3aa7e70..edfc3a0 100644 --- a/apps/web/src/components/allocations/FillOpenDemandModal.tsx +++ b/apps/web/src/components/allocations/FillOpenDemandModal.tsx @@ -3,6 +3,7 @@ import { useRef, useState, useMemo, useCallback } from "react"; import { AllocationStatus } from "@planarchy/shared"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; +import { formatDateMedium } from "~/lib/format.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -41,11 +42,6 @@ interface PlannedResource { estimatedCostCents: number; } -function fmtDate(date: Date | string): string { - const d = typeof date === "string" ? new Date(date) : date; - return d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" }); -} - export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) { // ─── Phase: "plan" (select resources) → "confirm" (review & submit) ── const [phase, setPhase] = useState<"plan" | "confirm">("plan"); @@ -209,7 +205,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{roleName}
- {allocation.project?.name} · {fmtDate(allocation.startDate)} – {fmtDate(allocation.endDate)} + {allocation.project?.name} · {formatDateMedium(allocation.startDate)} – {formatDateMedium(allocation.endDate)}
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index a9f2032..e695771 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -136,6 +136,38 @@ const adminNavEntries: AdminEntry[] = [ { href: "/admin/skill-import", label: "Skill Import", icon: }, ]; +/** + * Collect every href registered in the sidebar so that the active-check + * can determine whether a more-specific sibling matches the current path. + * Example: when pathname is `/vacations/my`, the item `/vacations` must NOT + * highlight because `/vacations/my` is a more-specific registered route. + */ +const ALL_NAV_HREFS: string[] = (() => { + const hrefs: string[] = []; + for (const section of navSections) { + for (const item of section.items) hrefs.push(item.href); + } + for (const entry of adminNavEntries) { + if (isSubGroup(entry)) { + for (const item of entry.items) hrefs.push(item.href); + } else { + hrefs.push(entry.href); + } + } + return hrefs; +})(); + +function isNavItemActive(pathname: string, href: string): boolean { + if (pathname === href) return true; + if (!pathname.startsWith(href + "/")) return false; + // pathname starts with `href/...` — but a more-specific registered route may match. + // If another nav href is a longer prefix match, this shorter one should NOT be active. + const hasMoreSpecificSibling = ALL_NAV_HREFS.some( + (other) => other !== href && other.length > href.length && pathname.startsWith(other), + ); + return !hasMoreSpecificSibling; +} + function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => void }) { const pathname = usePathname(); const [prefsOpen, setPrefsOpen] = useState(false); @@ -154,13 +186,13 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => const initial: Record = {}; for (const section of visibleSections) { if (section.collapsed) { - const hasActiveRoute = section.items.some((item) => pathname.startsWith(item.href)); + const hasActiveRoute = section.items.some((item) => isNavItemActive(pathname, item.href)); initial[section.label] = !hasActiveRoute; } } for (const entry of adminNavEntries) { if (isSubGroup(entry) && entry.collapsed) { - const hasActiveRoute = entry.items.some((item) => pathname.startsWith(item.href)); + const hasActiveRoute = entry.items.some((item) => isNavItemActive(pathname, item.href)); initial[entry.label] = !hasActiveRoute; } } @@ -230,7 +262,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => href={item.href as Route} className={clsx( "group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all", - pathname.startsWith(item.href) + isNavItemActive(pathname, item.href) ? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40" : "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white", )} @@ -285,7 +317,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => href={item.href as Route} className={clsx( "group flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-all", - pathname.startsWith(item.href) + isNavItemActive(pathname, item.href) ? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40" : "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white", )} @@ -304,7 +336,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => href={entry.href as Route} className={clsx( "group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all", - pathname.startsWith(entry.href) + isNavItemActive(pathname, entry.href) ? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40" : "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white", )} diff --git a/apps/web/src/components/layout/PreferencesModal.tsx b/apps/web/src/components/layout/PreferencesModal.tsx index 5a6b036..5835072 100644 --- a/apps/web/src/components/layout/PreferencesModal.tsx +++ b/apps/web/src/components/layout/PreferencesModal.tsx @@ -20,7 +20,7 @@ const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] = export function PreferencesModal({ onClose }: PreferencesModalProps) { const { prefs, setMode, setAccent } = useTheme(); - const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme } = useAppPreferences(); + const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects } = useAppPreferences(); return (
+ +
diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx index c5d4662..8f61213 100644 --- a/apps/web/src/components/timeline/TimelineContext.tsx +++ b/apps/web/src/components/timeline/TimelineContext.tsx @@ -43,6 +43,7 @@ export type TimelineProject = { clientId?: string | null; budgetCents?: number; winProbability?: number; + color?: string | null; staffingReqs?: unknown; responsiblePerson?: string | null; }; @@ -92,6 +93,7 @@ export type ProjectGroup = { startDate: Date; endDate: Date; status: string; + color?: string | null; resourceRows: { resource: ResourceBrief; allocs: TimelineAssignmentEntry[] }[]; }; @@ -206,9 +208,11 @@ export function TimelineProvider({ // Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1 const [filters, setFilters] = useState(() => { + const savedPrefs = readAppPreferences(); const base: TimelineFilters = { ...DEFAULT_FILTERS, - hideCompletedProjects: readAppPreferences().hideCompletedProjects, + hideCompletedProjects: savedPrefs.hideCompletedProjects, + showPlaceholders: savedPrefs.showDemandProjects, }; const eids = searchParams.get("eids"); if (eids) base.eids = eids.split(",").filter(Boolean); @@ -304,7 +308,7 @@ export function TimelineProvider({ const demands = entriesView?.demands ?? []; const { data: vacationEntries = [] } = trpc.vacation.list.useQuery( - { startDate: viewStart, endDate: viewEnd, status: VacationStatus.APPROVED, limit: 500 }, + { startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 }, { placeholderData: (prev) => prev }, ); @@ -477,6 +481,7 @@ export function TimelineProvider({ startDate: new Date(entry.project.startDate as unknown as string), endDate: new Date(entry.project.endDate as unknown as string), status: entry.project.status, + color: (entry.project as { color?: string | null }).color ?? null, resourceRows: [], }; projectGroupMap.set(entry.projectId, group); diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx index e22fc1f..419673d 100644 --- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx +++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx @@ -598,6 +598,7 @@ export function TimelineProjectPanel({ {row.type === "header" ? ( (() => { const { project } = row; + const customColor = project.color; const colors = ORDER_TYPE_COLORS[project.orderType] ?? { bg: "bg-gray-400", text: "text-white", @@ -652,14 +653,15 @@ export function TimelineProjectPanel({ isThisProjectShifting ? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]" : "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1", - colors.bg, - colors.text, + !customColor && colors.bg, + customColor ? "text-white" : colors.text, )} style={{ left: projLeft + 2, width: projWidth - 4, top: 8, height: 24, + ...(customColor ? { backgroundColor: customColor } : {}), }} onClick={() => { if (!dragState.isDragging) onOpenPanel(project.id); diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index c40bd2f..b5979e5 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -684,20 +684,22 @@ function renderVacationBlocksForRow(blocks: VacationBlockInfo[], rowHeight: numb const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40"; const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500"; const label = TYPE_LABELS_SHORT[v.type] ?? v.type; + const isPending = v.status === "PENDING"; return (
{width > 40 && ( - 🏖 {label} + {isPending ? "\u23F3" : "\uD83C\uDFD6"} {label} )}
@@ -776,6 +778,7 @@ function renderAllocBlocksFromData( const blockTop = 8 + lane * SUB_LANE_HEIGHT; const blockHeight = SUB_LANE_HEIGHT - 8; + const customColor = (alloc.project as { color?: string | null }).color; const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", @@ -800,8 +803,8 @@ function renderAllocBlocksFromData( key={alloc.id} className={clsx( "absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block", - colors.bg, - colors.text, + !customColor && colors.bg, + customColor ? "text-white" : colors.text, hasRecurrence && "opacity-80 border-2 border-dashed border-white/60", isBeingDragged ? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]" @@ -809,7 +812,13 @@ function renderAllocBlocksFromData( ? "opacity-30 z-[10]" : "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]", )} - style={{ left: left + 2, width: width - 4, top: blockTop, height: blockHeight }} + style={{ + left: left + 2, + width: width - 4, + top: blockTop, + height: blockHeight, + ...(customColor ? { backgroundColor: customColor } : {}), + }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/apps/web/src/components/timeline/TimelineToolbar.tsx b/apps/web/src/components/timeline/TimelineToolbar.tsx index 5c7890b..3bbea95 100644 --- a/apps/web/src/components/timeline/TimelineToolbar.tsx +++ b/apps/web/src/components/timeline/TimelineToolbar.tsx @@ -1,7 +1,10 @@ "use client"; import { clsx } from "clsx"; -import { useRef } from "react"; +import { useRef, useState } from "react"; +import { trpc } from "~/lib/trpc/client.js"; +import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js"; +import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js"; import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js"; import { TimelineQuickFilters } from "./TimelineQuickFilters.js"; @@ -50,6 +53,32 @@ export function TimelineToolbar({ filters.countryCodes.length; const filterAnchorRef = useRef(null); + // Track selected resource ID for the combobox (separate from the EID-based filter) + const [selectedResourceId, setSelectedResourceId] = useState(null); + + // Look up resource to get EID when selected + const { data: resourceLookup } = trpc.resource.list.useQuery( + { limit: 500 }, + { staleTime: 60_000 }, + ); + + function handleProjectChange(id: string | null) { + onFiltersChange({ ...filters, projectIds: id ? [id] : [] }); + } + + function handleResourceChange(id: string | null) { + setSelectedResourceId(id); + if (!id) { + onFiltersChange({ ...filters, eids: [] }); + return; + } + const resources = (resourceLookup?.resources ?? []) as Array<{ id: string; eid: string }>; + const resource = resources.find((r) => r.id === id); + if (resource?.eid) { + onFiltersChange({ ...filters, eids: [resource.eid] }); + } + } + function clearQuickFilters() { onFiltersChange({ ...filters, @@ -62,10 +91,24 @@ export function TimelineToolbar({ return (
-
- {viewMode === "resource" - ? `${resourceCount} resources · ${totalAllocCount} allocations` - : `${projectCount} projects`} +
+ + +
+ {viewMode === "resource" + ? `${resourceCount} resources \u00B7 ${totalAllocCount} allocations` + : `${projectCount} projects`} +
diff --git a/apps/web/src/components/vacations/MyVacationsClient.tsx b/apps/web/src/components/vacations/MyVacationsClient.tsx index 4929d35..cbafeb7 100644 --- a/apps/web/src/components/vacations/MyVacationsClient.tsx +++ b/apps/web/src/components/vacations/MyVacationsClient.tsx @@ -7,7 +7,7 @@ import { VacationModal } from "./VacationModal.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { BalanceCard } from "./BalanceCard.js"; import { VacationCalendar } from "./VacationCalendar.js"; -import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS } from "~/lib/status-styles.js"; +import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js"; export function MyVacationsClient() { const [showModal, setShowModal] = useState(false); @@ -109,7 +109,11 @@ export function MyVacationsClient() { return ( - {TYPE_LABELS[type] ?? type} + + + {TYPE_LABELS[type] ?? type} + + {start.toLocaleDateString("en-GB")} {end.toLocaleDateString("en-GB")} {vWithExtra.isHalfDay ? "0.5" : days} diff --git a/apps/web/src/components/vacations/TeamCalendar.tsx b/apps/web/src/components/vacations/TeamCalendar.tsx index e577f5b..a568fea 100644 --- a/apps/web/src/components/vacations/TeamCalendar.tsx +++ b/apps/web/src/components/vacations/TeamCalendar.tsx @@ -44,10 +44,9 @@ export function TeamCalendar() { { staleTime: 15_000 }, ); - // Distinct chapters for filter - const chapters = Array.from( - new Set((resources?.resources ?? []).map((r) => r.chapter).filter(Boolean) as string[]) - ).sort(); + // Fetch all chapters independently so the dropdown isn't affected by chapter filter + const { data: allChapters } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 }); + const chapters = allChapters ?? []; const resourceList = resources?.resources ?? []; const vacationList = (vacations ?? []).filter( diff --git a/apps/web/src/components/vacations/VacationClient.tsx b/apps/web/src/components/vacations/VacationClient.tsx index c5d80eb..f947186 100644 --- a/apps/web/src/components/vacations/VacationClient.tsx +++ b/apps/web/src/components/vacations/VacationClient.tsx @@ -10,7 +10,7 @@ import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { useTableSort } from "~/hooks/useTableSort.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; -import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS } from "~/lib/status-styles.js"; +import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js"; type VacationStatusFilter = VacationStatus | "ALL"; type VacationTypeFilter = VacationType | "ALL"; @@ -246,7 +246,7 @@ export function VacationClient() { {v.resource.displayName} ({v.resource.eid}) · - {TYPE_LABELS[v.type as VacationType]} + {TYPE_LABELS[v.type as VacationType]} · {new Date(v.startDate).toLocaleDateString("en-GB")} –{" "} @@ -380,7 +380,11 @@ export function VacationClient() { ({resource.eid}) )} - {TYPE_LABELS[type] ?? type} + + + {TYPE_LABELS[type] ?? type} + + {new Date(v.startDate).toLocaleDateString("en-GB")} {vExtra.isHalfDay && ½} diff --git a/apps/web/src/hooks/useAppPreferences.ts b/apps/web/src/hooks/useAppPreferences.ts index 32bcc8c..d24805c 100644 --- a/apps/web/src/hooks/useAppPreferences.ts +++ b/apps/web/src/hooks/useAppPreferences.ts @@ -16,6 +16,8 @@ export interface AppPreferences { timelineDisplayMode: "strip" | "bar" | "heatmap"; /** Color palette used for heatmap overlays and bar-mode project view bars. */ heatmapColorScheme: HeatmapColorScheme; + /** Show open demand / placeholder entries by default when loading the timeline. Default: true. */ + showDemandProjects: boolean; } const STORAGE_KEY = "planarchy_prefs"; @@ -25,6 +27,7 @@ const DEFAULT: AppPreferences = { hideCompletedProjects: true, timelineDisplayMode: "strip", heatmapColorScheme: "green-red", + showDemandProjects: true, }; export function readAppPreferences(): AppPreferences { @@ -83,5 +86,13 @@ export function useAppPreferences() { }); }, []); - return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme }; + const setShowDemandProjects = useCallback((value: boolean) => { + setPrefs((prev) => { + const next = { ...prev, showDemandProjects: value }; + saveAppPreferences(next); + return next; + }); + }, []); + + return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects }; } diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index bd64939..8fdde95 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -30,11 +30,24 @@ export function formatDateLong(d: Date | string): string { /** * Format integer cents as a currency string (e.g. "1.234 €"). - * Defaults to EUR with no decimal places. + * Defaults to EUR with no decimal places. Pass `fractionDigits: 2` for precise display. */ -export function formatMoney(cents: number | null | undefined, currency = "EUR"): string { +export function formatMoney(cents: number | null | undefined, currency = "EUR", fractionDigits = 0): string { const value = (cents ?? 0) / 100; - return new Intl.NumberFormat("de-DE", { style: "currency", currency, maximumFractionDigits: 0 }).format(value); + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }).format(value); +} + +/** + * Format a date as "16 Mar 2026" for compact display with year. + */ +export function formatDateMedium(d: Date | string | null | undefined): string { + if (!d) return ""; + return new Date(d).toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" }); } /** diff --git a/apps/web/src/lib/status-styles.ts b/apps/web/src/lib/status-styles.ts index 222e8da..e8dff18 100644 --- a/apps/web/src/lib/status-styles.ts +++ b/apps/web/src/lib/status-styles.ts @@ -25,6 +25,13 @@ export const VACATION_TYPE_LABELS: Record = { OTHER: "Other", }; +export const VACATION_TYPE_BADGE: Record = { + ANNUAL: "bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400", + SICK: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + PUBLIC_HOLIDAY: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400", + OTHER: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", +}; + export const PROJECT_STATUS_BADGE: Record = { DRAFT: "bg-gray-100 text-gray-700", ACTIVE: "bg-green-100 text-green-700", @@ -32,3 +39,10 @@ export const PROJECT_STATUS_BADGE: Record = { COMPLETED: "bg-blue-100 text-blue-700", CANCELLED: "bg-red-100 text-red-700", }; + +export const ORDER_TYPE_BADGE: Record = { + BD: "bg-purple-100 text-purple-700", + CHARGEABLE: "bg-green-100 text-green-700", + INTERNAL: "bg-blue-100 text-blue-700", + OVERHEAD: "bg-gray-100 text-gray-700", +}; diff --git a/docs/gitlooper-strategy.md b/docs/gitlooper-strategy.md new file mode 100644 index 0000000..ddc5a74 --- /dev/null +++ b/docs/gitlooper-strategy.md @@ -0,0 +1,121 @@ +# GitLooper Strategy + +**Date:** 2026-03-17 +**Epic:** Gitea Integration + Autonomous Issue Processing + +## Overview + +GitLooper is a Claude Code slash command (`/gitlooper:gitlooper`) that connects to Planarchy's Gitea instance, reads open issues, triages them, and autonomously implements fixes/features using spawned sub-agents. + +## Architecture + +``` +User runs /gitlooper:gitlooper + │ + ▼ +┌─────────────────────┐ +│ Phase 1: FETCH │ Gitea REST API → list open issues +│ & TRIAGE │ Classify: bug / feature / ux +│ │ Estimate effort: S / M / L +│ │ Present table → user approves +└────────┬────────────┘ + │ user picks issues + ▼ +┌─────────────────────┐ +│ Phase 2: SOLVE │ Per approved issue: +│ (sequential) │ 1. Comment on Gitea: "Working on this..." +│ │ 2. Analyze issue + screenshots +│ │ 3. Spawn coder agent (worktree) +│ │ 4. Spawn tester agent (verify) +│ │ 5. Merge + commit +└────────┬────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Phase 3: REPORT │ Comment resolution on Gitea +│ & CLOSE │ Close issue via API +└────────┬────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Phase 4: PUSH │ git push origin main +│ & SUMMARY │ Final summary table +└─────────────────────┘ +``` + +## Gitea Connection + +- **Instance:** `https://gitea.hartmut-noerenberg.com` +- **Repo:** `Hartmut/plANARCHY` +- **Auth:** Personal access token stored in `~/.gitea-token` +- **API Base:** `https://gitea.hartmut-noerenberg.com/api/v1` + +## Current Open Issues (2026-03-17) + +| # | Title | Type | Effort | Reporter | Priority | +|---|-------|------|--------|----------|----------| +| 3 | No Blueprints available in New Project Wizard | bug | S | Larissa | P1 | +| 5 | Account not linked to a resource | bug | S | Larissa | P1 | +| 7 | Dropdown menu broken in vacation management | bug | S | Larissa | P1 | +| 6 | My Vacations and Vacation Mgmt selected simultaneously | bug | S | Larissa | P2 | +| 10 | Sick leave not automatically added to timeline | bug | M | Larissa | P2 | +| 8 | Assign different color to Public Holidays vs Annual Vacation | ux | S | Larissa | P3 | +| 4 | Display Hours/Costs total per resource in Project Overview | feature | M | Larissa | P3 | +| 2 | Keep search bar locations consistent on all pages | feature | M | Larissa | P3 | +| 9 | Preferences: toggle "include demand projects" on page load | feature | M | Larissa | P4 | +| 1 | Assign a color to a project | feature | L | Larissa | P4 | + +### Triage Notes + +**P1 — Bugs (blocking):** +- **#3** — Blueprints likely not seeded in the new Gitea DB, or the query returns empty. Quick check of `blueprint.list` query. +- **#5** — User account created but `Resource.userId` not linked. Need to link Larissa's account to her resource record. +- **#7** — Chapter dropdown filter in VacationClient resets options after selecting one. Likely a state management bug in the filter component. + +**P2 — Bugs (non-blocking):** +- **#6** — AppShell sidebar highlights both "My Vacations" and "Vacation Mgmt" simultaneously. URL path matching issue. +- **#10** — Sick leave entries (vacation type SICK) not showing on timeline. The timeline `getEntries` query likely filters vacation types. + +**P3 — UX/Feature (quick wins):** +- **#8** — VacationClient uses same color for all vacation types. Add type-specific colors. +- **#4** — Add "Total Hours" and "Total Costs" columns to ProjectAssignmentsTable. +- **#2** — Add search bars to Timeline page matching Allocations page layout. + +**P4 — Feature (larger scope):** +- **#9** — User preferences system (new DB field or localStorage) for default filter state. +- **#1** — Project color field + color picker in UI + timeline rendering by project color. + +## Agent Spawning Strategy + +### For bugs (S effort): +Direct fix in main context — no worktree needed. Read, fix, test, commit. + +### For features (M effort): +Spawn a **coder** agent with `isolation: "worktree"` to keep main clean. Review output before merging. + +### For features (L effort): +Spawn a **planner** agent first to create implementation plan. Then spawn **coder** agent per task in the plan. Requires user approval at each stage. + +## Usage + +```bash +# Triage all open issues (dry run) +/gitlooper:gitlooper --dry-run + +# Work on all approved issues +/gitlooper:gitlooper + +# Work on a specific issue +/gitlooper:gitlooper 7 + +# Parallel mode (use with care) +/gitlooper:gitlooper --parallel +``` + +## Files Created + +| File | Purpose | +|------|---------| +| `.claude/commands/gitlooper/gitlooper.md` | Slash command definition | +| `~/.gitea-token` | API token (chmod 600, not in repo) | +| `docs/gitlooper-strategy.md` | This strategy document | diff --git a/packages/api/src/router/project-planning-read-model.ts b/packages/api/src/router/project-planning-read-model.ts index 3bad940..90e1218 100644 --- a/packages/api/src/router/project-planning-read-model.ts +++ b/packages/api/src/router/project-planning-read-model.ts @@ -27,6 +27,7 @@ export const PROJECT_PLANNING_ALLOCATION_INCLUDE = { endDate: true, staffingReqs: true, responsiblePerson: true, + color: true, }, }, roleEntity: { diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 86d2cc6..d07a443 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -134,6 +134,7 @@ export const projectRouter = createTRPCRouter({ endDate: input.endDate, status: input.status, responsiblePerson: input.responsiblePerson, + ...(input.color !== undefined ? { color: input.color } : {}), staffingReqs: input.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue, dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue, blueprintId: input.blueprintId, @@ -185,6 +186,7 @@ export const projectRouter = createTRPCRouter({ ...(input.data.endDate !== undefined ? { endDate: input.data.endDate } : {}), ...(input.data.status !== undefined ? { status: input.data.status } : {}), ...(input.data.responsiblePerson !== undefined ? { responsiblePerson: input.data.responsiblePerson } : {}), + ...(input.data.color !== undefined ? { color: input.data.color } : {}), ...(input.data.staffingReqs !== undefined ? { staffingReqs: input.data.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}), ...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}), ...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}), diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 567c3c0..0002810 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -65,7 +65,7 @@ export const userRouter = createTRPCRouter({ const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); - return ctx.db.user.create({ + const user = await ctx.db.user.create({ data: { email: input.email, name: input.name, @@ -74,6 +74,20 @@ export const userRouter = createTRPCRouter({ }, select: { id: true, name: true, email: true, systemRole: true }, }); + + // Auto-link to a resource with matching email (if one exists and isn't already linked) + const matchingResource = await ctx.db.resource.findFirst({ + where: { email: input.email, userId: null }, + select: { id: true }, + }); + if (matchingResource) { + await ctx.db.resource.update({ + where: { id: matchingResource.id }, + data: { userId: user.id }, + }); + } + + return user; }), updateRole: adminProcedure @@ -91,6 +105,56 @@ export const userRouter = createTRPCRouter({ }); }), + // ─── Resource Linking ────────────────────────────────────────────────── + + linkResource: adminProcedure + .input(z.object({ userId: z.string(), resourceId: z.string().nullable() })) + .mutation(async ({ ctx, input }) => { + if (input.resourceId) { + // Unlink any resource previously linked to this user + await ctx.db.resource.updateMany({ + where: { userId: input.userId }, + data: { userId: null }, + }); + // Link the new resource + await ctx.db.resource.update({ + where: { id: input.resourceId }, + data: { userId: input.userId }, + }); + } else { + // Unlink + await ctx.db.resource.updateMany({ + where: { userId: input.userId }, + data: { userId: null }, + }); + } + return { success: true }; + }), + + autoLinkAllByEmail: adminProcedure.mutation(async ({ ctx }) => { + // Find all users without a linked resource, then match by email + const unlinkedUsers = await ctx.db.user.findMany({ + where: { resource: null }, + select: { id: true, email: true }, + }); + + let linked = 0; + for (const user of unlinkedUsers) { + const resource = await ctx.db.resource.findFirst({ + where: { email: user.email, userId: null }, + select: { id: true }, + }); + if (resource) { + await ctx.db.resource.update({ + where: { id: resource.id }, + data: { userId: user.id }, + }); + linked++; + } + } + return { linked, checked: unlinkedUsers.length }; + }), + getDashboardLayout: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 71eba46..5722bfe 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -82,7 +82,7 @@ export const vacationRouter = createTRPCRouter({ .input( z.object({ resourceId: z.string().optional(), - status: z.nativeEnum(VacationStatus).optional(), + status: z.union([z.nativeEnum(VacationStatus), z.array(z.nativeEnum(VacationStatus))]).optional(), type: z.nativeEnum(VacationType).optional(), startDate: z.coerce.date().optional(), endDate: z.coerce.date().optional(), @@ -93,7 +93,7 @@ export const vacationRouter = createTRPCRouter({ const vacations = await ctx.db.vacation.findMany({ where: { ...(input.resourceId ? { resourceId: input.resourceId } : {}), - ...(input.status ? { status: input.status } : {}), + ...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}), ...(input.type ? { type: input.type } : {}), ...(input.startDate ? { endDate: { gte: input.startDate } } : {}), ...(input.endDate ? { startDate: { lte: input.endDate } } : {}), diff --git a/packages/application/src/__tests__/delete-assignment.test.ts b/packages/application/src/__tests__/delete-assignment.test.ts index c4fab91..27b8200 100644 --- a/packages/application/src/__tests__/delete-assignment.test.ts +++ b/packages/application/src/__tests__/delete-assignment.test.ts @@ -2,16 +2,21 @@ import { describe, expect, it, vi } from "vitest"; import { deleteAssignment } from "../index.js"; describe("deleteAssignment", () => { - it("deletes an explicit assignment row", async () => { + it("deletes an assignment without demand link", async () => { const db = { assignment: { findUnique: vi.fn().mockResolvedValue({ id: "assignment_1", projectId: "project_1", resourceId: "resource_1", + demandRequirementId: null, }), delete: vi.fn().mockResolvedValue({}), }, + demandRequirement: { + findUnique: vi.fn(), + update: vi.fn(), + }, }; const result = await deleteAssignment(db as never, "assignment_1"); @@ -20,9 +25,76 @@ describe("deleteAssignment", () => { deletedId: "assignment_1", projectId: "project_1", resourceId: "resource_1", + reopenedDemandId: null, }); expect(db.assignment.delete).toHaveBeenCalledWith({ where: { id: "assignment_1" }, }); + expect(db.demandRequirement.findUnique).not.toHaveBeenCalled(); + }); + + it("re-opens a COMPLETED demand when its assignment is deleted", async () => { + const db = { + assignment: { + findUnique: vi.fn().mockResolvedValue({ + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + demandRequirementId: "demand_1", + }), + delete: vi.fn().mockResolvedValue({}), + }, + demandRequirement: { + findUnique: vi.fn().mockResolvedValue({ + id: "demand_1", + headcount: 0, + status: "COMPLETED", + }), + update: vi.fn().mockResolvedValue({}), + }, + }; + + const result = await deleteAssignment(db as never, "assignment_1"); + + expect(result).toEqual({ + deletedId: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + reopenedDemandId: "demand_1", + }); + expect(db.demandRequirement.update).toHaveBeenCalledWith({ + where: { id: "demand_1" }, + data: { headcount: 1, status: "ACTIVE" }, + }); + }); + + it("increments headcount on an ACTIVE demand when its assignment is deleted", async () => { + const db = { + assignment: { + findUnique: vi.fn().mockResolvedValue({ + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + demandRequirementId: "demand_2", + }), + delete: vi.fn().mockResolvedValue({}), + }, + demandRequirement: { + findUnique: vi.fn().mockResolvedValue({ + id: "demand_2", + headcount: 2, + status: "ACTIVE", + }), + update: vi.fn().mockResolvedValue({}), + }, + }; + + const result = await deleteAssignment(db as never, "assignment_2"); + + expect(result.reopenedDemandId).toBe("demand_2"); + expect(db.demandRequirement.update).toHaveBeenCalledWith({ + where: { id: "demand_2" }, + data: { headcount: 3, status: "ACTIVE" }, + }); }); }); diff --git a/packages/application/src/use-cases/allocation/delete-assignment.ts b/packages/application/src/use-cases/allocation/delete-assignment.ts index a9a08e1..2f43bba 100644 --- a/packages/application/src/use-cases/allocation/delete-assignment.ts +++ b/packages/application/src/use-cases/allocation/delete-assignment.ts @@ -1,13 +1,15 @@ import type { Prisma, PrismaClient } from "@planarchy/db"; +import { AllocationStatus } from "@planarchy/shared"; type DbClient = - | Pick - | Pick; + | Pick + | Pick; export interface DeleteAssignmentResult { deletedId: string; projectId: string; resourceId: string; + reopenedDemandId: string | null; } export async function deleteAssignment( @@ -20,6 +22,7 @@ export async function deleteAssignment( id: true, projectId: true, resourceId: true, + demandRequirementId: true, }, }); @@ -31,9 +34,31 @@ export async function deleteAssignment( where: { id: assignment.id }, }); + // Reverse demand fill progress: re-open the demand if the assignment was linked + let reopenedDemandId: string | null = null; + if (assignment.demandRequirementId) { + const demand = await db.demandRequirement.findUnique({ + where: { id: assignment.demandRequirementId }, + select: { id: true, headcount: true, status: true }, + }); + + if (demand) { + const wasCompleted = demand.status === AllocationStatus.COMPLETED; + await db.demandRequirement.update({ + where: { id: demand.id }, + data: { + headcount: wasCompleted ? 1 : demand.headcount + 1, + status: AllocationStatus.ACTIVE, + }, + }); + reopenedDemandId = demand.id; + } + } + return { deletedId: assignment.id, projectId: assignment.projectId, resourceId: assignment.resourceId, + reopenedDemandId, }; } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a72c2ec..3a33023 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -806,6 +806,7 @@ model Project { endDate DateTime @db.Date status ProjectStatus @default(DRAFT) responsiblePerson String? + color String? // Hex color for timeline display, e.g. "#3b82f6" // staffingReqs: StaffingRequirement[] staffingReqs Json @db.JsonB @default("[]") diff --git a/packages/shared/src/schemas/project.schema.ts b/packages/shared/src/schemas/project.schema.ts index b4c9cb8..a0ac569 100644 --- a/packages/shared/src/schemas/project.schema.ts +++ b/packages/shared/src/schemas/project.schema.ts @@ -29,6 +29,7 @@ export const CreateProjectBaseSchema = z.object({ blueprintId: z.string().optional(), status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT), responsiblePerson: z.string().max(200).optional(), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color like #3b82f6").optional(), utilizationCategoryId: z.string().optional(), clientId: z.string().optional(), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96ba89c..019057a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-force-graph-3d: + specifier: ^1.29.1 + version: 1.29.1(react@19.2.4) react-grid-layout: specifier: ^2.2.2 version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -92,6 +95,9 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.1 + three: + specifier: ^0.183.2 + version: 0.183.2 xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -117,6 +123,9 @@ importers: '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/three': + specifier: ^0.183.1 + version: 0.183.1 autoprefixer: specifier: ^10.4.20 version: 10.4.27(postcss@8.5.8) @@ -353,6 +362,10 @@ importers: packages: + 3d-force-graph@1.79.1: + resolution: {integrity: sha512-iscIVt4jWjJ11KEEswgOIOWk8Ew4EFKHRyERJXJ0ouycqzHCtWwb9E5imnxS5rYF1f1IESkFNAfB+h3EkU0Irw==} + engines: {node: '>=12'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -375,6 +388,9 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -1304,6 +1320,12 @@ packages: peerDependencies: typescript: '>=5.7.2' + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + + '@tweenjs/tween.js@25.0.0': + resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1364,9 +1386,18 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.183.1': + resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1455,9 +1486,16 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@webgpu/types@0.1.69': + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + abs-svg-path@0.1.1: resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + accessor-fn@1.5.3: + resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==} + engines: {node: '>=12'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1749,14 +1787,25 @@ packages: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} + d3-binarytree@1.0.2: + resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} + d3-force-3d@3.0.6: + resolution: {integrity: sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==} + engines: {node: '>=12'} + d3-format@3.1.2: resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} engines: {node: '>=12'} @@ -1765,14 +1814,29 @@ packages: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-octree@1.1.0: + resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==} + d3-path@3.1.0: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + d3-scale@4.0.2: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -1789,6 +1853,10 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-bind-mapper@1.0.3: + resolution: {integrity: sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==} + engines: {node: '>=12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2064,6 +2132,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2083,6 +2154,10 @@ packages: flatted@3.3.4: resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + float-tooltip@1.7.5: + resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==} + engines: {node: '>=12'} + fontkit@2.0.4: resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} @@ -2379,6 +2454,10 @@ packages: jay-peg@1.1.1: resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==} + jerrypick@1.1.2: + resolution: {integrity: sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==} + engines: {node: '>=12'} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -2409,6 +2488,10 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + kapsule@1.16.3: + resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==} + engines: {node: '>=12'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2440,6 +2523,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2507,6 +2593,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.0.1: + resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2580,6 +2669,21 @@ packages: sass: optional: true + ngraph.events@1.4.0: + resolution: {integrity: sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==} + + ngraph.forcelayout@3.3.1: + resolution: {integrity: sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==} + + ngraph.graph@20.1.2: + resolution: {integrity: sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==} + + ngraph.merge@1.0.0: + resolution: {integrity: sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==} + + ngraph.random@1.2.0: + resolution: {integrity: sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -2724,6 +2828,10 @@ packages: engines: {node: '>=18'} hasBin: true + polished@4.3.1: + resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} + engines: {node: '>=10'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2828,6 +2936,12 @@ packages: react: '>= 16.3.0' react-dom: '>= 16.3.0' + react-force-graph-3d@1.29.1: + resolution: {integrity: sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg==} + engines: {node: '>=12'} + peerDependencies: + react: '*' + react-grid-layout@2.2.2: resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==} peerDependencies: @@ -2837,6 +2951,12 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-kapsule@2.5.7: + resolution: {integrity: sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.13.1' + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -3131,6 +3251,21 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three-forcegraph@1.43.1: + resolution: {integrity: sha512-lQnYPLvR31gb91mF5xHhU0jPHJgBPw9QB23R6poCk8Tgvz8sQtq7wTxwClcPdfKCBbHXsb7FSqK06Osiu1kQ5A==} + engines: {node: '>=12'} + peerDependencies: + three: '>=0.118.3' + + three-render-objects@1.40.5: + resolution: {integrity: sha512-iA+rYdal0tkond37YeXIvEMAxUFGxw1wU6+ce/GsuiOUKL+8zaxFXY7PTVft0F+Km50mbmtKQ24b2FdwSG3p3A==} + engines: {node: '>=12'} + peerDependencies: + three: '>=0.168' + + three@0.183.2: + resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -3140,6 +3275,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -3419,6 +3557,14 @@ packages: snapshots: + 3d-force-graph@1.79.1: + dependencies: + accessor-fn: 1.5.3 + kapsule: 1.16.3 + three: 0.183.2 + three-forcegraph: 1.43.1(three@0.183.2) + three-render-objects: 1.40.5(three@0.183.2) + '@alloc/quick-lru@5.2.0': {} '@auth/core@0.41.0': @@ -3431,6 +3577,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -4150,6 +4298,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@tweenjs/tween.js@23.1.3': {} + + '@tweenjs/tween.js@25.0.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -4210,8 +4362,22 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/stats.js@0.17.4': {} + + '@types/three@0.183.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.69 + fflate: 0.8.2 + meshoptimizer: 1.0.1 + '@types/use-sync-external-store@0.0.6': {} + '@types/webxr@0.5.24': {} + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4343,8 +4509,12 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@webgpu/types@0.1.69': {} + abs-svg-path@0.1.1: {} + accessor-fn@1.5.3: {} + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -4673,18 +4843,39 @@ snapshots: dependencies: internmap: 2.0.3 + d3-binarytree@1.0.2: {} + d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + d3-ease@3.0.1: {} + d3-force-3d@3.0.6: + dependencies: + d3-binarytree: 1.0.2 + d3-dispatch: 3.0.1 + d3-octree: 1.1.0 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + d3-format@3.1.2: {} d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 + d3-octree@1.1.0: {} + d3-path@3.1.0: {} + d3-quadtree@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + d3-scale@4.0.2: dependencies: d3-array: 3.2.4 @@ -4693,6 +4884,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -4707,6 +4900,10 @@ snapshots: d3-timer@3.0.1: {} + data-bind-mapper@1.0.3: + dependencies: + accessor-fn: 1.5.3 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -5103,6 +5300,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5123,6 +5322,12 @@ snapshots: flatted@3.3.4: {} + float-tooltip@1.7.5: + dependencies: + d3-selection: 3.0.0 + kapsule: 1.16.3 + preact: 10.24.3 + fontkit@2.0.4: dependencies: '@swc/helpers': 0.5.15 @@ -5435,6 +5640,8 @@ snapshots: dependencies: restructure: 3.0.2 + jerrypick@1.1.2: {} + jiti@1.21.7: {} jose@6.1.3: {} @@ -5462,6 +5669,10 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + kapsule@1.16.3: + dependencies: + lodash-es: 4.17.23 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5494,6 +5705,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.23: {} + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} @@ -5540,6 +5753,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.0.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -5605,6 +5820,22 @@ snapshots: - '@babel/core' - babel-plugin-macros + ngraph.events@1.4.0: {} + + ngraph.forcelayout@3.3.1: + dependencies: + ngraph.events: 1.4.0 + ngraph.merge: 1.0.0 + ngraph.random: 1.2.0 + + ngraph.graph@20.1.2: + dependencies: + ngraph.events: 1.4.0 + + ngraph.merge@1.0.0: {} + + ngraph.random@1.2.0: {} + node-releases@2.0.27: {} nodemailer@8.0.1: {} @@ -5725,6 +5956,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + polished@4.3.1: + dependencies: + '@babel/runtime': 7.28.6 + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.8): @@ -5815,6 +6050,13 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + react-force-graph-3d@1.29.1(react@19.2.4): + dependencies: + 3d-force-graph: 1.79.1 + prop-types: 15.8.1 + react: 19.2.4 + react-kapsule: 2.5.7(react@19.2.4) + react-grid-layout@2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: clsx: 2.1.1 @@ -5828,6 +6070,11 @@ snapshots: react-is@16.13.1: {} + react-kapsule@2.5.7(react@19.2.4): + dependencies: + jerrypick: 1.1.2 + react: 19.2.4 + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -6237,12 +6484,39 @@ snapshots: dependencies: any-promise: 1.3.0 + three-forcegraph@1.43.1(three@0.183.2): + dependencies: + accessor-fn: 1.5.3 + d3-array: 3.2.4 + d3-force-3d: 3.0.6 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + data-bind-mapper: 1.0.3 + kapsule: 1.16.3 + ngraph.forcelayout: 3.3.1 + ngraph.graph: 20.1.2 + three: 0.183.2 + tinycolor2: 1.6.0 + + three-render-objects@1.40.5(three@0.183.2): + dependencies: + '@tweenjs/tween.js': 25.0.0 + accessor-fn: 1.5.3 + float-tooltip: 1.7.5 + kapsule: 1.16.3 + polished: 4.3.1 + three: 0.183.2 + + three@0.183.2: {} + tiny-inflate@1.0.3: {} tiny-invariant@1.3.3: {} tinybench@2.9.0: {} + tinycolor2@1.6.0: {} + tinyexec@0.3.2: {} tinyglobby@0.2.15: diff --git a/review-report.md b/review-report.md index 4311269..9167fe5 100644 --- a/review-report.md +++ b/review-report.md @@ -1,98 +1,77 @@ -# Review-Report — 2026-03-15 +# Review-Report — 2026-03-15 (3D Computation Graph) ## Ergebnis: ✅ Bestanden -Alle Quality Gates bestanden. Keine kritischen Probleme gefunden. Zwei Minor-Issues behoben waehrend des Reviews. +Alle Quality Gates bestanden. Keine kritischen Probleme. Zwei Minor-Empfehlungen. ## Quality Gates | Gate | Status | Details | |------|--------|---------| -| Engine Tests | ✅ | 254/254 bestanden (17 Dateien) | -| Staffing Tests | ✅ | 37/37 bestanden (3 Dateien) | -| API Tests | ✅ | 209/209 bestanden (21 Dateien) | -| Application Tests | ✅ | 67/67 bestanden (15 Dateien) | -| **Gesamt** | **✅** | **567/567 Tests bestanden** | -| TypeScript (web) | ✅ | 0 Fehler | -| TypeScript (api) | ✅ | 0 Fehler | -| Paketabhaengigkeiten | ✅ | Keine Zyklen | +| Engine Tests | ✅ | 283/283 (19 files) | +| Staffing Tests | ✅ | 37/37 (3 files) | +| API Tests | ✅ | 209/209 (21 files) | +| Application Tests | ✅ | 67/67 (15 files) | +| TypeScript (web) | ✅ | 0 errors (excl. BlueprintFieldEditor TS2589) | +| TypeScript (api) | ✅ | 0 errors | -## Architektur-Checkliste +## Code-Review-Checkliste -- [x] Keine zirkulaeren Abhaengigkeiten — engine, staffing, ui sind sauber isoliert -- [x] `engine` und `staffing` haben keine DB-Imports -- [x] Alle 24 tRPC-Router in `packages/api/src/router/index.ts` registriert -- [x] SSE-Events: 10 Emitter fuer Allocation, Project, Budget, Vacation, Role, Notification -- [x] SSE-Debouncing (50ms) aktiv in event-bus.ts +### Architektur +- [x] Keine zirkulaeren Abhaengigkeiten — `api → engine/shared/db` (erlaubt) +- [x] `engine` und `staffing` unveraendert, keine DB-Imports +- [x] Neuer Router `computationGraph` in `index.ts` registriert +- [x] Keine SSE-Events noetig (read-only Feature, keine Mutations) -## TypeScript & Typsicherheit +### TypeScript & Typsicherheit +- [x] `any`-Types nur an `react-force-graph-3d`-Grenzen mit `eslint-disable` Kommentar (6 Stellen) +- [x] Prisma-Enums gecastet: `pa.status as unknown as string` + `as Parameters[2]` +- [x] JSONB-Feld gecastet: `commercialTerms as { contingencyPercent?: number; ... } | null` +- [x] `scheduleRules as SpainScheduleRule | null` korrekt +- [x] `exactOptionalPropertyTypes` beachtet: `...(formula ? { formula } : {})` Pattern -- [x] Keine unkommentierten `any`-Types — alle Vorkommnisse haben `// eslint-disable-next-line` oder sind intentionale Casts -- [x] Prisma-Enums an Client-Grenzen mit `as unknown as SharedType` gecastet -- [x] JSONB-Felder korrekt gecastet -- [x] Nullable FKs mit optional chaining behandelt -- [x] `exactOptionalPropertyTypes` Pattern eingehalten (Spread statt `{ key: undefined }`) +### Datenbank & Prisma +- [x] Keine Schema-Aenderungen — rein lesende Queries +- [x] Geldbetraege in Integer-Cents: `lcrCents`, `dailyCostCents`, `budgetCents` etc. +- [x] Kein Seed noetig (kein neues Modell) -## Datenbank & Prisma +### UI & Komponenten +- [x] `"use client"` Direktive gesetzt +- [x] Three.js via `dynamic(() => import(...), { ssr: false })` — kein SSR-Problem +- [x] Neue Seite in AppShell-Navigation ergaenzt ("Computation Graph" unter Analytics) +- [x] Opake Hintergruende: `bg-zinc-50`, `bg-zinc-900/95` (95% ist akzeptabel fuer Tooltip) -- [x] Geldbetraege als Integer-Cents -- [x] Kein unsicheres Raw-SQL in App-Routern — einziges `$executeRaw` in resource.ts nutzt tagged template literals (parameterisiert) -- [x] Composite Indexes fuer Assignment, DemandRequirement, Vacation vorhanden +### Sicherheit +- [x] Beide Procedures nutzen `controllerProcedure` (ADMIN + MANAGER + CONTROLLER) +- [x] Keine Raw-Queries — nur Prisma `findMany`/`findUniqueOrThrow` +- [x] Keine sensiblen Daten im Response — nur berechnete Werte und Formeln -## Sicherheit +## Gefundene Probleme -- [x] `protectedProcedure` erfordert jetzt `session.user` UND `dbUser` (gehaertet) -- [x] Vacation create/cancel: Ownership-Check fuer USER-Rolle -- [x] `user.list` auf `adminProcedure` eingeschraenkt -- [x] `entitlement.getBalance`: Ownership-Check fuer USER-Rolle -- [x] Allocation listView/list/listDemands/listAssignments: Anonymisierung angewendet -- [x] Security Headers in next.config.ts (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) -- [x] Keine Passwoerter/Secrets in tRPC-Responses — `settings.ts` gibt nur `hasApiKey`/`hasSmtpPassword` Booleans zurueck -- [x] Alle Mutations hinter `managerProcedure` oder `adminProcedure` -- [x] Kein `publicProcedure` in Routern +### Kritisch +Keine. -## UI & Komponenten +### Minor -- [x] AppShell: Kollabierbare Navigationsgruppen (Estimating, ACN-Orga) -- [x] Tailwind-Opacity nur in 5er-Schritten (CSS-Fix fuer `/92`, `/88`, `/94`) -- [x] `trpc.role.list` korrekt als Array behandelt +1. **Duplizierte Types** — `GraphNode`, `GraphLink`, `Domain` sind sowohl in `packages/api/.../computation-graph.ts` als auch `apps/web/.../domain-colors.ts` definiert. Funktioniert (tRPC inferiert die Typen), aber bei Aenderungen muss man beide Stellen anpassen. Empfehlung: Types nach `@planarchy/shared` verschieben wenn sie stabil sind. -## Waehrend des Reviews behoben +2. **`project.list` Query** — Der Client castet das Ergebnis via `(projectData as any)?.projects ?? (projectData as any)`. Das deutet auf Unsicherheit ueber das Return-Format hin. Sollte nach dem Merge geprueft werden, ob `.projects` oder direkt das Array zurueckkommt. -### Minor: assignment-bookings Test-Sync -- **Datei:** `packages/application/src/__tests__/assignment-bookings.test.ts` -- **Problem:** `clientId: true` wurde zum `project` Select in `list-assignment-bookings.ts` hinzugefuegt, aber 3 Test-Assertions nicht aktualisiert (pre-existing) -- **Fix:** `clientId: true` in alle 3 Test-Select-Assertions eingefuegt → 67/67 Tests bestanden +### Empfehlungen -## Offene Items (nicht-blockierend) +1. **Bundle Size Monitoring** — `three` und `react-force-graph-3d` fuegen ~700KB (gzipped) hinzu. Dank `dynamic import` + `{ ssr: false }` trifft das nur die Computation Graph Seite. Trotzdem: bei der naechsten Bundle-Analyse verifizieren. -Diese stammen aus dem Security-Audit und sind im Backlog (`docs/security-audit-2026-03-15.md`): +2. **E2E-Test** — Aktuell kein Test fuer die neue Seite. Ein Playwright-Smoke-Test (`navigate to /analytics/computation-graph, expect canvas element`) waere sinnvoll. -1. **SSE Event-Filterung** — Events werden global an alle authentifizierten User gebroadcastet, ohne Rollen-/Projekt-Scoping -2. **Rate Limiting** — Kein Rate Limiter auf Auth, Admin-Tests, oder API-Endpunkten -3. **Passwort-Policy** — Nur `min(8)` ohne Komplexitaetsanforderungen -4. **xlsx Parser** — Bekannte Advisories (Prototype Pollution, ReDoS) in `xlsx@0.18.5` -5. **JWT maxAge** — Auth.js Default 30 Tage ohne explizite Konfiguration -6. **Next.js Middleware** — Kein `middleware.ts` fuer serverseitige Route-Protection - -## Empfehlungen - -1. SSE-Event-Filterung priorisieren — groesstes verbleibendes Datenschutz-Risiko -2. Rate Limiting mit `rate-limiter-flexible` + Redis einbauen (Auth zuerst) -3. `xlsx` durch `exceljs` oder `SheetJS Pro` ersetzen fuer untrusted Parsing -4. `middleware.ts` fuer `/(app)/` Routen einfuegen +3. **Link Formula Labels** — Plan sah Three.js Text-Sprites auf Kanten vor (E3). Die Formeln sind in den Link-Daten vorhanden (`formula` Feld), werden aber aktuell nur bei Hover (indirekt ueber Node-Tooltip) sichtbar. Kann als Follow-up ergaenzt werden. ## Learnings-Vorschlag fuer LEARNINGS.md -``` -### Security: protectedProcedure muss dbUser pruefen (2026-03-15) -`protectedProcedure` pruefte nur `session.user`, nicht ob der DB-User noch existiert. -Geloest: `dbUser`-Check in die Middleware eingefuegt. Stale Sessions werden jetzt abgelehnt. -Konsequenz: Alle Test-Caller muessen ein gueltiges `dbUser`-Objekt mitgeben. +```markdown +### react-force-graph-3d in Next.js 15 -### Security: IDOR-Checks bei Self-Service-Endpunkten (2026-03-15) -Vacation create/cancel und Entitlement getBalance hatten keine Ownership-Checks. -USER-Rolle konnte Aktionen fuer beliebige Ressourcen ausfuehren. -Pattern: Bei `protectedProcedure`-Endpunkten die eine `resourceId` akzeptieren, -immer `resource.userId === ctx.dbUser.id` pruefen fuer nicht-privilegierte Rollen. +- Muss als `dynamic(() => import("react-force-graph-3d"), { ssr: false })` geladen werden +- React 19 Kompatibilitaet: funktioniert, aber TypeScript-Generics sind loose — `as any` Cast + eslint-disable noetig bei Callbacks (`onNodeClick`, `nodeThreeObject`, `linkColor`) +- Node-Rendering via Canvas-basierte Three.js Sprites (nicht HTML-Overlays) — performanter bei 50+ Knoten +- `warmupTicks={50}` + `cooldownTicks={0}` verhindert die Kraft-Simulation und nutzt stattdessen die fixen `fx/fy/fz` Positionen ```