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:
@@ -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
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +288,22 @@ export function UsersClient() {
|
||||
Manage user roles and permission overrides
|
||||
</p>
|
||||
</div>
|
||||
<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); }}
|
||||
@@ -292,6 +315,7 @@ export function UsersClient() {
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,11 +91,25 @@ export function TimelineToolbar({
|
||||
|
||||
return (
|
||||
<div className="app-toolbar flex flex-wrap items-center justify-between gap-3">
|
||||
<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 · ${totalAllocCount} allocations`
|
||||
? `${resourceCount} resources \u00B7 ${totalAllocCount} allocations`
|
||||
: `${projectCount} projects`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<TimelineQuickFilters filters={filters} onChange={onFiltersChange} />
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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 ?? "" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Generated
+274
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user