feat: project colors, timeline filters, sidebar fix, GitLooper agent, and misc improvements

- Fix sidebar double-highlight on /vacations/my (Gitea #6): add isNavItemActive() helper
- Add project color picker (schema + API + modal + timeline rendering)
- Add ProjectCombobox/ResourceCombobox to timeline toolbar
- Show PENDING vacations on timeline with dashed/dimmed style
- Add "show demand projects" preference with localStorage persistence
- Add ProjectAssignmentsTable with total hours/cost columns
- Extend vacation API to accept status arrays
- Add GitLooper formal YAML agent configuration
- Extend user admin with permission overrides UI
- Add delete-assignment use case tests
- Add status-styles.ts shared badge constants
- Centralize formatMoney/formatCents in format.ts

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-17 10:22:52 +01:00
parent b0e55786c3
commit eb283147d1
34 changed files with 1545 additions and 255 deletions
+446
View File
@@ -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 <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
- `<number>`: 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
+4 -1
View File
@@ -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",
@@ -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<string, string> = {
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<string, string> = {
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" },
+4 -89
View File
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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) */}
<BudgetStatusCard projectId={project.id} />
{/* Assignments table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">
Assignments ({project.assignments.length})
</h2>
</div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resource</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role <InfoTooltip content="Role this allocation was created for." />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Period <InfoTooltip content="Start and end date of the allocation." />
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Hours/Day <InfoTooltip content="Planned working hours per calendar day." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Daily Cost <InfoTooltip content="Resource LCR × hours per day." />
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status <InfoTooltip content="PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed." />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{project.assignments.map((assignment) => (
<tr key={assignment.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3 text-sm font-medium text-gray-900">
{assignment.resource?.displayName ?? "—"}
{assignment.resource?.eid && (
<span className="ml-1.5 text-xs text-gray-400 font-mono">{assignment.resource.eid}</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{assignment.role || "—"}</td>
<td className="px-4 py-3 text-xs text-gray-500">
{formatDate(assignment.startDate)}
{" → "}
{formatDate(assignment.endDate)}
</td>
<td className="px-4 py-3 text-sm text-gray-900">{assignment.hoursPerDay}h</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[assignment.status] ?? "bg-gray-100 text-gray-600"}`}
>
{assignment.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
{project.assignments.length === 0 && (
<div className="text-center py-12 text-gray-500 text-sm">No assignments for this project.</div>
)}
</div>
{/* Assignments table (client component with delete action) */}
<ProjectAssignmentsTable assignments={project.assignments as never} />
{/* Open demands table (client component with fill action) */}
<ProjectDemandsTable
+34 -10
View File
@@ -125,6 +125,13 @@ export function UsersClient() {
onError: (err) => 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
</p>
</div>
<button
type="button"
onClick={() => { setCreateState({ ...EMPTY_CREATE }); setActionError(null); }}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Create User
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void autoLinkMutation.mutateAsync().then((r) => {
setActionError(r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`);
if (r.linked > 0) setActionError(null);
})}
disabled={autoLinkMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
title="Auto-link user accounts to resources by matching email addresses"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{autoLinkMutation.isPending ? "Linking..." : "Auto-link Resources"}
</button>
<button
type="button"
onClick={() => { setCreateState({ ...EMPTY_CREATE }); setActionError(null); }}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Create User
</button>
</div>
</div>
{/* Filters */}
@@ -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
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{allocation.project?.name} · {fmtDate(allocation.startDate)} {fmtDate(allocation.endDate)}
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} {formatDateMedium(allocation.endDate)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
+37 -5
View File
@@ -136,6 +136,38 @@ const adminNavEntries: AdminEntry[] = [
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
];
/**
* 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<string, boolean> = {};
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",
)}
@@ -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 (
<div
@@ -245,6 +245,33 @@ export function PreferencesModal({ onClose }: PreferencesModalProps) {
</span>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer mt-3">
<div className="relative mt-0.5 flex-shrink-0">
<input
type="checkbox"
checked={appPrefs.showDemandProjects}
onChange={(e) => setShowDemandProjects(e.target.checked)}
className="sr-only peer"
/>
<div className={clsx(
"w-9 h-5 rounded-full transition-colors",
appPrefs.showDemandProjects ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
)} />
<div className={clsx(
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
appPrefs.showDemandProjects ? "translate-x-4" : "translate-x-0",
)} />
</div>
<div>
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
Include demand projects on load
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Show open staffing demands (dashed bars) when loading pages.
</span>
</div>
</label>
</div>
{/* Preview note */}
@@ -1,6 +1,7 @@
"use client";
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
interface Warning {
level: string;
@@ -16,14 +17,7 @@ interface BudgetStatusBarProps {
className?: string;
}
function formatEur(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
const formatEur = (cents: number) => formatMoney(cents, "EUR", 2);
function getConfirmedBarColor(utilizationPercent: number): string {
if (utilizationPercent > 95) return "bg-red-600";
@@ -1,6 +1,7 @@
"use client";
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
import { BudgetStatusBar } from "./BudgetStatusBar.js";
@@ -8,14 +9,7 @@ interface BudgetStatusCardProps {
projectId: string;
}
function formatEur(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
const formatEur = (cents: number) => formatMoney(cents, "EUR", 2);
function WarningIcon({ level }: { level: string }) {
if (level === "critical") {
@@ -0,0 +1,204 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { formatDate, formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ALLOCATION_STATUS_BADGE } from "~/lib/status-styles.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { trpc } from "~/lib/trpc/client.js";
function countWorkingDays(start: Date | string, end: Date | string): number {
const s = new Date(start);
const e = new Date(end);
let days = 0;
const cur = new Date(s);
while (cur <= e) {
if (cur.getDay() !== 0 && cur.getDay() !== 6) days++;
cur.setDate(cur.getDate() + 1);
}
return days;
}
interface AssignmentRow {
id: string;
role: string | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
dailyCostCents: number;
status: string;
resource?: { id: string; displayName: string; eid: string } | null;
}
interface ProjectAssignmentsTableProps {
assignments: AssignmentRow[];
}
export function ProjectAssignmentsTable({ assignments }: ProjectAssignmentsTableProps) {
const { canEdit } = usePermissions();
const router = useRouter();
const utils = trpc.useUtils();
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
const deleteMutation = trpc.allocation.deleteAssignment.useMutation({
onSuccess: () => {
void utils.allocation.list.invalidate();
void utils.allocation.listView.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
router.refresh();
},
});
async function handleDelete(id: string) {
setDeletingId(id);
try {
await deleteMutation.mutateAsync({ id });
} finally {
setDeletingId(null);
setConfirmId(null);
}
}
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Assignments ({assignments.length})
</h2>
</div>
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Resource</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role <InfoTooltip content="Role this allocation was created for." />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Period <InfoTooltip content="Start and end date of the allocation." />
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Hours/Day <InfoTooltip content="Planned working hours per calendar day." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Daily Cost <InfoTooltip content="Resource LCR x hours per day." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Total Hours <InfoTooltip content="Working days in period x hours per day." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Total Cost <InfoTooltip content="Working days x daily cost." />
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status <InfoTooltip content="PROPOSED = requested, CONFIRMED = approved, ACTIVE = ongoing, COMPLETED = finished, CANCELLED = removed." />
</th>
{canEdit && (
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{assignments.map((assignment) => (
<tr key={assignment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors">
<td className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{assignment.resource?.displayName ?? "\u2014"}
{assignment.resource?.eid && (
<span className="ml-1.5 text-xs text-gray-400 font-mono">{assignment.resource.eid}</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{assignment.role || "\u2014"}</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{formatDate(assignment.startDate)} {"\u2192"} {formatDate(assignment.endDate)}
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{assignment.hoursPerDay}h</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })}
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{(countWorkingDays(assignment.startDate, assignment.endDate) * assignment.hoursPerDay).toLocaleString("de-DE", { minimumFractionDigits: 1 })}h
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{formatMoney(countWorkingDays(assignment.startDate, assignment.endDate) * assignment.dailyCostCents, "EUR", 2)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOCATION_STATUS_BADGE[assignment.status] ?? "bg-gray-100 text-gray-600"}`}
>
{assignment.status}
</span>
</td>
{canEdit && (
<td className="px-4 py-3 text-right">
{confirmId === assignment.id ? (
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => void handleDelete(assignment.id)}
disabled={deletingId === assignment.id}
className="text-xs font-medium text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-200 disabled:opacity-50"
>
{deletingId === assignment.id ? "Deleting..." : "Confirm"}
</button>
<button
type="button"
onClick={() => setConfirmId(null)}
disabled={deletingId === assignment.id}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmId(assignment.id)}
className="text-xs font-medium text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-200"
>
Delete
</button>
)}
</td>
)}
</tr>
))}
</tbody>
{assignments.length > 0 && (() => {
const totalHours = assignments.reduce((sum, a) => sum + countWorkingDays(a.startDate, a.endDate) * a.hoursPerDay, 0);
const totalCostCents = assignments.reduce((sum, a) => sum + countWorkingDays(a.startDate, a.endDate) * a.dailyCostCents, 0);
return (
<tfoot className="border-t-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50">
<tr>
<td colSpan={4} className="px-4 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300 text-right">Totals</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{"\u2014"}</td>
<td className="px-4 py-3 text-sm font-semibold text-right text-gray-900 dark:text-gray-100">
{totalHours.toLocaleString("de-DE", { minimumFractionDigits: 1 })}h
</td>
<td className="px-4 py-3 text-sm font-semibold text-right text-gray-900 dark:text-gray-100">
{formatMoney(totalCostCents, "EUR", 2)}
</td>
<td className="px-4 py-3">{"\u2014"}</td>
{canEdit && <td />}
</tr>
</tfoot>
);
})()}
</table>
{assignments.length === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No assignments for this project.</div>
)}
</div>
);
}
@@ -10,14 +10,7 @@ import type { AllocationWithDetails } from "@planarchy/shared";
import type { OpenDemandAssignment } from "~/components/timeline/TimelineProjectPanel.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { trpc } from "~/lib/trpc/client.js";
const ALLOC_STATUS_COLORS: Record<string, string> = {
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",
COMPLETED: "bg-blue-100 text-blue-600",
};
import { ALLOCATION_STATUS_BADGE as ALLOC_STATUS_COLORS } from "~/lib/status-styles.js";
interface DemandRow {
id: string;
@@ -46,6 +46,7 @@ interface FormState {
endDate: string;
status: string;
responsiblePerson: string;
color: string;
utilizationCategoryId: string;
clientId: string;
}
@@ -63,6 +64,7 @@ function getDefaultForm(): FormState {
endDate: today,
status: "DRAFT",
responsiblePerson: "",
color: "",
utilizationCategoryId: "",
clientId: "",
};
@@ -80,6 +82,7 @@ function projectToForm(project: Project): FormState {
endDate: formatDateForInput(project.endDate),
status: project.status,
responsiblePerson: project.responsiblePerson ?? "",
color: (project as unknown as { color?: string | null }).color ?? "",
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
};
@@ -201,6 +204,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
endDate: new Date(form.endDate),
status: form.status as unknown as ProjectStatus,
responsiblePerson: form.responsiblePerson.trim() || undefined,
...(form.color ? { color: form.color } : {}),
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
},
@@ -219,6 +223,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
staffingReqs: [],
dynamicFields: {},
responsiblePerson: form.responsiblePerson.trim() || undefined,
...(form.color ? { color: form.color } : {}),
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
});
@@ -515,6 +520,32 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
className={inputClass}
/>
</div>
<div>
<label className={labelClass} htmlFor="projectColor">
Timeline Color
</label>
<div className="flex items-center gap-2">
<input
id="projectColor"
type="color"
value={form.color || "#3b82f6"}
onChange={(e) => setField("color", e.target.value)}
className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-600 cursor-pointer p-0.5"
/>
<span className="text-xs text-gray-400 dark:text-gray-500">
{form.color || "Default"}
</span>
{form.color && (
<button
type="button"
onClick={() => setField("color", "")}
className="text-xs text-gray-400 hover:text-red-500"
>
Clear
</button>
)}
</div>
</div>
</div>
</fieldset>
</div>
@@ -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<TimelineFilters>(() => {
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);
@@ -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);
@@ -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 (
<div
key={`vac-${v.id}`}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden border-t-2 pointer-events-none",
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
colorClass,
borderClass,
isPending ? "border-t-2 border-dashed opacity-60" : "border-t-2",
isPending ? "" : borderClass,
)}
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
>
{width > 40 && (
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
🏖 {label}
{isPending ? "\u23F3" : "\uD83C\uDFD6"} {label}
</span>
)}
</div>
@@ -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();
@@ -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<HTMLDivElement | null>(null);
// Track selected resource ID for the combobox (separate from the EID-based filter)
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(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 (
<div className="app-toolbar flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-gray-500 dark:text-gray-400">
{viewMode === "resource"
? `${resourceCount} resources · ${totalAllocCount} allocations`
: `${projectCount} projects`}
<div className="flex items-center gap-3">
<ProjectCombobox
value={filters.projectIds[0] ?? null}
onChange={handleProjectChange}
placeholder="Filter by project..."
className="min-w-[220px]"
/>
<ResourceCombobox
value={selectedResourceId}
onChange={handleResourceChange}
placeholder="Filter by resource..."
className="min-w-[180px]"
/>
<div className="text-sm text-gray-500 dark:text-gray-400">
{viewMode === "resource"
? `${resourceCount} resources \u00B7 ${totalAllocCount} allocations`
: `${projectCount} projects`}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
@@ -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 (
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{TYPE_LABELS[type] ?? type}</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[type] ?? "bg-gray-100 text-gray-600"}`}>
{TYPE_LABELS[type] ?? type}
</span>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{start.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{end.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{vWithExtra.isHalfDay ? "0.5" : days}</td>
@@ -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(
@@ -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() {
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">{v.resource.displayName}</span>
<span className="text-xs text-gray-500 dark:text-gray-400">({v.resource.eid})</span>
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{TYPE_LABELS[v.type as VacationType]}</span>
<span className={`inline-flex px-1.5 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[v.type as string] ?? "bg-gray-100 text-gray-600"}`}>{TYPE_LABELS[v.type as VacationType]}</span>
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(v.startDate).toLocaleDateString("en-GB")} {" "}
@@ -380,7 +380,11 @@ export function VacationClient() {
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({resource.eid})</span>
)}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{TYPE_LABELS[type] ?? type}</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[type] ?? "bg-gray-100 text-gray-600"}`}>
{TYPE_LABELS[type] ?? type}
</span>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(v.startDate).toLocaleDateString("en-GB")}
{vExtra.isHalfDay && <span className="ml-1 text-xs text-gray-400 dark:text-gray-500">½</span>}
+12 -1
View File
@@ -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 };
}
+16 -3
View File
@@ -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" });
}
/**
+14
View File
@@ -25,6 +25,13 @@ export const VACATION_TYPE_LABELS: Record<string, string> = {
OTHER: "Other",
};
export const VACATION_TYPE_BADGE: Record<string, string> = {
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<string, string> = {
DRAFT: "bg-gray-100 text-gray-700",
ACTIVE: "bg-green-100 text-green-700",
@@ -32,3 +39,10 @@ export const PROJECT_STATUS_BADGE: Record<string, string> = {
COMPLETED: "bg-blue-100 text-blue-700",
CANCELLED: "bg-red-100 text-red-700",
};
export const ORDER_TYPE_BADGE: Record<string, string> = {
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",
};
+121
View File
@@ -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 |
@@ -27,6 +27,7 @@ export const PROJECT_PLANNING_ALLOCATION_INCLUDE = {
endDate: true,
staffingReqs: true,
responsiblePerson: true,
color: true,
},
},
roleEntity: {
+2
View File
@@ -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 } : {}),
+65 -1
View File
@@ -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 ?? "" },
+2 -2
View File
@@ -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 } } : {}),
@@ -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" },
});
});
});
@@ -1,13 +1,15 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared";
type DbClient =
| Pick<PrismaClient, "assignment">
| Pick<Prisma.TransactionClient, "assignment">;
| Pick<PrismaClient, "assignment" | "demandRequirement">
| Pick<Prisma.TransactionClient, "assignment" | "demandRequirement">;
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,
};
}
+1
View File
@@ -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("[]")
@@ -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(),
});
+274
View File
@@ -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:
+49 -70
View File
@@ -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<typeof computeBudgetStatus>[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
```