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": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@planarchy/application": "workspace:*",
|
|
||||||
"@planarchy/api": "workspace:*",
|
"@planarchy/api": "workspace:*",
|
||||||
|
"@planarchy/application": "workspace:*",
|
||||||
"@planarchy/db": "workspace:*",
|
"@planarchy/db": "workspace:*",
|
||||||
"@planarchy/engine": "workspace:*",
|
"@planarchy/engine": "workspace:*",
|
||||||
"@planarchy/shared": "workspace:*",
|
"@planarchy/shared": "workspace:*",
|
||||||
@@ -29,10 +29,12 @@
|
|||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-force-graph-3d": "^1.29.1",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.2",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"three": "^0.183.2",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
"@types/react": "^19.0.6",
|
"@types/react": "^19.0.6",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@types/react-grid-layout": "^2.1.0",
|
"@types/react-grid-layout": "^2.1.0",
|
||||||
|
"@types/three": "^0.183.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
|||||||
@@ -25,23 +25,10 @@ import { useRowOrder } from "~/hooks/useRowOrder.js";
|
|||||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.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 ────────────────────────────────────────────────────────────────
|
// ─── 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 = [
|
const ALL_STATUSES = [
|
||||||
{ value: "DRAFT", label: "Draft" },
|
{ value: "DRAFT", label: "Draft" },
|
||||||
{ value: "ACTIVE", label: "Active" },
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
|||||||
@@ -5,34 +5,13 @@ import { createCaller } from "~/server/trpc.js";
|
|||||||
import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js";
|
import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js";
|
||||||
import { ProjectDetailActions } from "~/components/projects/ProjectDetailClient.js";
|
import { ProjectDetailActions } from "~/components/projects/ProjectDetailClient.js";
|
||||||
import { ProjectDemandsTable } from "~/components/projects/ProjectDemandsTable.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 {
|
interface ProjectDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
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) {
|
export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const trpc = await createCaller();
|
const trpc = await createCaller();
|
||||||
@@ -125,72 +104,8 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
|
|||||||
{/* Budget status card (client component) */}
|
{/* Budget status card (client component) */}
|
||||||
<BudgetStatusCard projectId={project.id} />
|
<BudgetStatusCard projectId={project.id} />
|
||||||
|
|
||||||
{/* Assignments table */}
|
{/* Assignments table (client component with delete action) */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
<ProjectAssignmentsTable assignments={project.assignments as never} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Open demands table (client component with fill action) */}
|
{/* Open demands table (client component with fill action) */}
|
||||||
<ProjectDemandsTable
|
<ProjectDemandsTable
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ export function UsersClient() {
|
|||||||
onError: (err) => setActionError(err.message),
|
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({
|
const resetPermissionsMutation = trpc.user.resetPermissions.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.user.list.invalidate();
|
await utils.user.list.invalidate();
|
||||||
@@ -281,6 +288,22 @@ export function UsersClient() {
|
|||||||
Manage user roles and permission overrides
|
Manage user roles and permission overrides
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setCreateState({ ...EMPTY_CREATE }); setActionError(null); }}
|
onClick={() => { setCreateState({ ...EMPTY_CREATE }); setActionError(null); }}
|
||||||
@@ -292,6 +315,7 @@ export function UsersClient() {
|
|||||||
Create User
|
Create User
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRef, useState, useMemo, useCallback } from "react";
|
import { useRef, useState, useMemo, useCallback } from "react";
|
||||||
import { AllocationStatus } from "@planarchy/shared";
|
import { AllocationStatus } from "@planarchy/shared";
|
||||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
|
import { formatDateMedium } from "~/lib/format.js";
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
@@ -41,11 +42,6 @@ interface PlannedResource {
|
|||||||
estimatedCostCents: number;
|
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) {
|
export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) {
|
||||||
// ─── Phase: "plan" (select resources) → "confirm" (review & submit) ──
|
// ─── Phase: "plan" (select resources) → "confirm" (review & submit) ──
|
||||||
const [phase, setPhase] = useState<"plan" | "confirm">("plan");
|
const [phase, setPhase] = useState<"plan" | "confirm">("plan");
|
||||||
@@ -209,7 +205,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
|
<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">
|
<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>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
|
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
|
||||||
|
|||||||
@@ -136,6 +136,38 @@ const adminNavEntries: AdminEntry[] = [
|
|||||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
{ 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 }) {
|
function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||||
@@ -154,13 +186,13 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
|
|||||||
const initial: Record<string, boolean> = {};
|
const initial: Record<string, boolean> = {};
|
||||||
for (const section of visibleSections) {
|
for (const section of visibleSections) {
|
||||||
if (section.collapsed) {
|
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;
|
initial[section.label] = !hasActiveRoute;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of adminNavEntries) {
|
for (const entry of adminNavEntries) {
|
||||||
if (isSubGroup(entry) && entry.collapsed) {
|
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;
|
initial[entry.label] = !hasActiveRoute;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +262,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
|
|||||||
href={item.href as Route}
|
href={item.href as Route}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
|
"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"
|
? "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",
|
: "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}
|
href={item.href as Route}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-all",
|
"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"
|
? "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",
|
: "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}
|
href={entry.href as Route}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
|
"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"
|
? "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",
|
: "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) {
|
export function PreferencesModal({ onClose }: PreferencesModalProps) {
|
||||||
const { prefs, setMode, setAccent } = useTheme();
|
const { prefs, setMode, setAccent } = useTheme();
|
||||||
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme } = useAppPreferences();
|
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects } = useAppPreferences();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -245,6 +245,33 @@ export function PreferencesModal({ onClose }: PreferencesModalProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Preview note */}
|
{/* Preview note */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
|
|
||||||
interface Warning {
|
interface Warning {
|
||||||
level: string;
|
level: string;
|
||||||
@@ -16,14 +17,7 @@ interface BudgetStatusBarProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEur(cents: number): string {
|
const formatEur = (cents: number) => formatMoney(cents, "EUR", 2);
|
||||||
return (cents / 100).toLocaleString("de-DE", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "EUR",
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfirmedBarColor(utilizationPercent: number): string {
|
function getConfirmedBarColor(utilizationPercent: number): string {
|
||||||
if (utilizationPercent > 95) return "bg-red-600";
|
if (utilizationPercent > 95) return "bg-red-600";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { BudgetStatusBar } from "./BudgetStatusBar.js";
|
import { BudgetStatusBar } from "./BudgetStatusBar.js";
|
||||||
|
|
||||||
@@ -8,14 +9,7 @@ interface BudgetStatusCardProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEur(cents: number): string {
|
const formatEur = (cents: number) => formatMoney(cents, "EUR", 2);
|
||||||
return (cents / 100).toLocaleString("de-DE", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "EUR",
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function WarningIcon({ level }: { level: string }) {
|
function WarningIcon({ level }: { level: string }) {
|
||||||
if (level === "critical") {
|
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 type { OpenDemandAssignment } from "~/components/timeline/TimelineProjectPanel.js";
|
||||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { ALLOCATION_STATUS_BADGE as ALLOC_STATUS_COLORS } from "~/lib/status-styles.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",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DemandRow {
|
interface DemandRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ interface FormState {
|
|||||||
endDate: string;
|
endDate: string;
|
||||||
status: string;
|
status: string;
|
||||||
responsiblePerson: string;
|
responsiblePerson: string;
|
||||||
|
color: string;
|
||||||
utilizationCategoryId: string;
|
utilizationCategoryId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
}
|
}
|
||||||
@@ -63,6 +64,7 @@ function getDefaultForm(): FormState {
|
|||||||
endDate: today,
|
endDate: today,
|
||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
responsiblePerson: "",
|
responsiblePerson: "",
|
||||||
|
color: "",
|
||||||
utilizationCategoryId: "",
|
utilizationCategoryId: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
};
|
};
|
||||||
@@ -80,6 +82,7 @@ function projectToForm(project: Project): FormState {
|
|||||||
endDate: formatDateForInput(project.endDate),
|
endDate: formatDateForInput(project.endDate),
|
||||||
status: project.status,
|
status: project.status,
|
||||||
responsiblePerson: project.responsiblePerson ?? "",
|
responsiblePerson: project.responsiblePerson ?? "",
|
||||||
|
color: (project as unknown as { color?: string | null }).color ?? "",
|
||||||
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
||||||
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
|
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
|
||||||
};
|
};
|
||||||
@@ -201,6 +204,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
|
|||||||
endDate: new Date(form.endDate),
|
endDate: new Date(form.endDate),
|
||||||
status: form.status as unknown as ProjectStatus,
|
status: form.status as unknown as ProjectStatus,
|
||||||
responsiblePerson: form.responsiblePerson.trim() || undefined,
|
responsiblePerson: form.responsiblePerson.trim() || undefined,
|
||||||
|
...(form.color ? { color: form.color } : {}),
|
||||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||||
},
|
},
|
||||||
@@ -219,6 +223,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
|
|||||||
staffingReqs: [],
|
staffingReqs: [],
|
||||||
dynamicFields: {},
|
dynamicFields: {},
|
||||||
responsiblePerson: form.responsiblePerson.trim() || undefined,
|
responsiblePerson: form.responsiblePerson.trim() || undefined,
|
||||||
|
...(form.color ? { color: form.color } : {}),
|
||||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||||
});
|
});
|
||||||
@@ -515,6 +520,32 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
|
|||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type TimelineProject = {
|
|||||||
clientId?: string | null;
|
clientId?: string | null;
|
||||||
budgetCents?: number;
|
budgetCents?: number;
|
||||||
winProbability?: number;
|
winProbability?: number;
|
||||||
|
color?: string | null;
|
||||||
staffingReqs?: unknown;
|
staffingReqs?: unknown;
|
||||||
responsiblePerson?: string | null;
|
responsiblePerson?: string | null;
|
||||||
};
|
};
|
||||||
@@ -92,6 +93,7 @@ export type ProjectGroup = {
|
|||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
status: string;
|
status: string;
|
||||||
|
color?: string | null;
|
||||||
resourceRows: { resource: ResourceBrief; allocs: TimelineAssignmentEntry[] }[];
|
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
|
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
|
||||||
const [filters, setFilters] = useState<TimelineFilters>(() => {
|
const [filters, setFilters] = useState<TimelineFilters>(() => {
|
||||||
|
const savedPrefs = readAppPreferences();
|
||||||
const base: TimelineFilters = {
|
const base: TimelineFilters = {
|
||||||
...DEFAULT_FILTERS,
|
...DEFAULT_FILTERS,
|
||||||
hideCompletedProjects: readAppPreferences().hideCompletedProjects,
|
hideCompletedProjects: savedPrefs.hideCompletedProjects,
|
||||||
|
showPlaceholders: savedPrefs.showDemandProjects,
|
||||||
};
|
};
|
||||||
const eids = searchParams.get("eids");
|
const eids = searchParams.get("eids");
|
||||||
if (eids) base.eids = eids.split(",").filter(Boolean);
|
if (eids) base.eids = eids.split(",").filter(Boolean);
|
||||||
@@ -304,7 +308,7 @@ export function TimelineProvider({
|
|||||||
const demands = entriesView?.demands ?? [];
|
const demands = entriesView?.demands ?? [];
|
||||||
|
|
||||||
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
|
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 },
|
{ placeholderData: (prev) => prev },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -477,6 +481,7 @@ export function TimelineProvider({
|
|||||||
startDate: new Date(entry.project.startDate as unknown as string),
|
startDate: new Date(entry.project.startDate as unknown as string),
|
||||||
endDate: new Date(entry.project.endDate as unknown as string),
|
endDate: new Date(entry.project.endDate as unknown as string),
|
||||||
status: entry.project.status,
|
status: entry.project.status,
|
||||||
|
color: (entry.project as { color?: string | null }).color ?? null,
|
||||||
resourceRows: [],
|
resourceRows: [],
|
||||||
};
|
};
|
||||||
projectGroupMap.set(entry.projectId, group);
|
projectGroupMap.set(entry.projectId, group);
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ export function TimelineProjectPanel({
|
|||||||
{row.type === "header" ? (
|
{row.type === "header" ? (
|
||||||
(() => {
|
(() => {
|
||||||
const { project } = row;
|
const { project } = row;
|
||||||
|
const customColor = project.color;
|
||||||
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
|
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
|
||||||
bg: "bg-gray-400",
|
bg: "bg-gray-400",
|
||||||
text: "text-white",
|
text: "text-white",
|
||||||
@@ -652,14 +653,15 @@ export function TimelineProjectPanel({
|
|||||||
isThisProjectShifting
|
isThisProjectShifting
|
||||||
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
|
? "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",
|
: "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
|
||||||
colors.bg,
|
!customColor && colors.bg,
|
||||||
colors.text,
|
customColor ? "text-white" : colors.text,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: projLeft + 2,
|
left: projLeft + 2,
|
||||||
width: projWidth - 4,
|
width: projWidth - 4,
|
||||||
top: 8,
|
top: 8,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
...(customColor ? { backgroundColor: customColor } : {}),
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!dragState.isDragging) onOpenPanel(project.id);
|
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 colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
|
||||||
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
|
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
|
||||||
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
|
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
|
||||||
|
const isPending = v.status === "PENDING";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`vac-${v.id}`}
|
key={`vac-${v.id}`}
|
||||||
className={clsx(
|
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,
|
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 }}
|
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
|
||||||
>
|
>
|
||||||
{width > 40 && (
|
{width > 40 && (
|
||||||
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -776,6 +778,7 @@ function renderAllocBlocksFromData(
|
|||||||
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
|
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
|
||||||
const blockHeight = SUB_LANE_HEIGHT - 8;
|
const blockHeight = SUB_LANE_HEIGHT - 8;
|
||||||
|
|
||||||
|
const customColor = (alloc.project as { color?: string | null }).color;
|
||||||
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
|
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
|
||||||
bg: "bg-gray-400",
|
bg: "bg-gray-400",
|
||||||
text: "text-white",
|
text: "text-white",
|
||||||
@@ -800,8 +803,8 @@ function renderAllocBlocksFromData(
|
|||||||
key={alloc.id}
|
key={alloc.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block",
|
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block",
|
||||||
colors.bg,
|
!customColor && colors.bg,
|
||||||
colors.text,
|
customColor ? "text-white" : colors.text,
|
||||||
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
|
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
|
||||||
isBeingDragged
|
isBeingDragged
|
||||||
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]"
|
? "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]"
|
? "opacity-30 z-[10]"
|
||||||
: "hover:ring-2 hover:ring-white hover:ring-offset-1 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) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { clsx } from "clsx";
|
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 { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
|
||||||
import { TimelineQuickFilters } from "./TimelineQuickFilters.js";
|
import { TimelineQuickFilters } from "./TimelineQuickFilters.js";
|
||||||
|
|
||||||
@@ -50,6 +53,32 @@ export function TimelineToolbar({
|
|||||||
filters.countryCodes.length;
|
filters.countryCodes.length;
|
||||||
const filterAnchorRef = useRef<HTMLDivElement | null>(null);
|
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() {
|
function clearQuickFilters() {
|
||||||
onFiltersChange({
|
onFiltersChange({
|
||||||
...filters,
|
...filters,
|
||||||
@@ -62,11 +91,25 @@ export function TimelineToolbar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-toolbar flex flex-wrap items-center justify-between gap-3">
|
<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">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{viewMode === "resource"
|
{viewMode === "resource"
|
||||||
? `${resourceCount} resources · ${totalAllocCount} allocations`
|
? `${resourceCount} resources \u00B7 ${totalAllocCount} allocations`
|
||||||
: `${projectCount} projects`}
|
: `${projectCount} projects`}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<TimelineQuickFilters filters={filters} onChange={onFiltersChange} />
|
<TimelineQuickFilters filters={filters} onChange={onFiltersChange} />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { VacationModal } from "./VacationModal.js";
|
|||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { BalanceCard } from "./BalanceCard.js";
|
import { BalanceCard } from "./BalanceCard.js";
|
||||||
import { VacationCalendar } from "./VacationCalendar.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() {
|
export function MyVacationsClient() {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -109,7 +109,11 @@ export function MyVacationsClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
<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">{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">{end.toLocaleDateString("en-GB")}</td>
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{vWithExtra.isHalfDay ? "0.5" : days}</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 },
|
{ staleTime: 15_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Distinct chapters for filter
|
// Fetch all chapters independently so the dropdown isn't affected by chapter filter
|
||||||
const chapters = Array.from(
|
const { data: allChapters } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
|
||||||
new Set((resources?.resources ?? []).map((r) => r.chapter).filter(Boolean) as string[])
|
const chapters = allChapters ?? [];
|
||||||
).sort();
|
|
||||||
|
|
||||||
const resourceList = resources?.resources ?? [];
|
const resourceList = resources?.resources ?? [];
|
||||||
const vacationList = (vacations ?? []).filter(
|
const vacationList = (vacations ?? []).filter(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
|||||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.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 VacationStatusFilter = VacationStatus | "ALL";
|
||||||
type VacationTypeFilter = VacationType | "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="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="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="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="mx-1 text-gray-300 dark:text-gray-600">·</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{new Date(v.startDate).toLocaleDateString("en-GB")} –{" "}
|
{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>
|
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({resource.eid})</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||||
{new Date(v.startDate).toLocaleDateString("en-GB")}
|
{new Date(v.startDate).toLocaleDateString("en-GB")}
|
||||||
{vExtra.isHalfDay && <span className="ml-1 text-xs text-gray-400 dark:text-gray-500">½</span>}
|
{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";
|
timelineDisplayMode: "strip" | "bar" | "heatmap";
|
||||||
/** Color palette used for heatmap overlays and bar-mode project view bars. */
|
/** Color palette used for heatmap overlays and bar-mode project view bars. */
|
||||||
heatmapColorScheme: HeatmapColorScheme;
|
heatmapColorScheme: HeatmapColorScheme;
|
||||||
|
/** Show open demand / placeholder entries by default when loading the timeline. Default: true. */
|
||||||
|
showDemandProjects: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "planarchy_prefs";
|
const STORAGE_KEY = "planarchy_prefs";
|
||||||
@@ -25,6 +27,7 @@ const DEFAULT: AppPreferences = {
|
|||||||
hideCompletedProjects: true,
|
hideCompletedProjects: true,
|
||||||
timelineDisplayMode: "strip",
|
timelineDisplayMode: "strip",
|
||||||
heatmapColorScheme: "green-red",
|
heatmapColorScheme: "green-red",
|
||||||
|
showDemandProjects: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function readAppPreferences(): AppPreferences {
|
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 €").
|
* 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;
|
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",
|
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> = {
|
export const PROJECT_STATUS_BADGE: Record<string, string> = {
|
||||||
DRAFT: "bg-gray-100 text-gray-700",
|
DRAFT: "bg-gray-100 text-gray-700",
|
||||||
ACTIVE: "bg-green-100 text-green-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",
|
COMPLETED: "bg-blue-100 text-blue-700",
|
||||||
CANCELLED: "bg-red-100 text-red-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,
|
endDate: true,
|
||||||
staffingReqs: true,
|
staffingReqs: true,
|
||||||
responsiblePerson: true,
|
responsiblePerson: true,
|
||||||
|
color: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
roleEntity: {
|
roleEntity: {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
endDate: input.endDate,
|
endDate: input.endDate,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
responsiblePerson: input.responsiblePerson,
|
responsiblePerson: input.responsiblePerson,
|
||||||
|
...(input.color !== undefined ? { color: input.color } : {}),
|
||||||
staffingReqs: input.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
staffingReqs: input.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||||
dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||||
blueprintId: input.blueprintId,
|
blueprintId: input.blueprintId,
|
||||||
@@ -185,6 +186,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
...(input.data.endDate !== undefined ? { endDate: input.data.endDate } : {}),
|
...(input.data.endDate !== undefined ? { endDate: input.data.endDate } : {}),
|
||||||
...(input.data.status !== undefined ? { status: input.data.status } : {}),
|
...(input.data.status !== undefined ? { status: input.data.status } : {}),
|
||||||
...(input.data.responsiblePerson !== undefined ? { responsiblePerson: input.data.responsiblePerson } : {}),
|
...(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.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.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
|
||||||
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
|
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
const { hash } = await import("@node-rs/argon2");
|
const { hash } = await import("@node-rs/argon2");
|
||||||
const passwordHash = await hash(input.password);
|
const passwordHash = await hash(input.password);
|
||||||
|
|
||||||
return ctx.db.user.create({
|
const user = await ctx.db.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: input.email,
|
email: input.email,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -74,6 +74,20 @@ export const userRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
select: { id: true, name: true, email: true, systemRole: true },
|
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
|
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 }) => {
|
getDashboardLayout: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await ctx.db.user.findUnique({
|
const user = await ctx.db.user.findUnique({
|
||||||
where: { email: ctx.session.user?.email ?? "" },
|
where: { email: ctx.session.user?.email ?? "" },
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
resourceId: z.string().optional(),
|
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(),
|
type: z.nativeEnum(VacationType).optional(),
|
||||||
startDate: z.coerce.date().optional(),
|
startDate: z.coerce.date().optional(),
|
||||||
endDate: z.coerce.date().optional(),
|
endDate: z.coerce.date().optional(),
|
||||||
@@ -93,7 +93,7 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
const vacations = await ctx.db.vacation.findMany({
|
const vacations = await ctx.db.vacation.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(input.resourceId ? { resourceId: input.resourceId } : {}),
|
...(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.type ? { type: input.type } : {}),
|
||||||
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
|
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
|
||||||
...(input.endDate ? { startDate: { lte: input.endDate } } : {}),
|
...(input.endDate ? { startDate: { lte: input.endDate } } : {}),
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { deleteAssignment } from "../index.js";
|
import { deleteAssignment } from "../index.js";
|
||||||
|
|
||||||
describe("deleteAssignment", () => {
|
describe("deleteAssignment", () => {
|
||||||
it("deletes an explicit assignment row", async () => {
|
it("deletes an assignment without demand link", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
assignment: {
|
assignment: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "assignment_1",
|
id: "assignment_1",
|
||||||
projectId: "project_1",
|
projectId: "project_1",
|
||||||
resourceId: "resource_1",
|
resourceId: "resource_1",
|
||||||
|
demandRequirementId: null,
|
||||||
}),
|
}),
|
||||||
delete: vi.fn().mockResolvedValue({}),
|
delete: vi.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await deleteAssignment(db as never, "assignment_1");
|
const result = await deleteAssignment(db as never, "assignment_1");
|
||||||
@@ -20,9 +25,76 @@ describe("deleteAssignment", () => {
|
|||||||
deletedId: "assignment_1",
|
deletedId: "assignment_1",
|
||||||
projectId: "project_1",
|
projectId: "project_1",
|
||||||
resourceId: "resource_1",
|
resourceId: "resource_1",
|
||||||
|
reopenedDemandId: null,
|
||||||
});
|
});
|
||||||
expect(db.assignment.delete).toHaveBeenCalledWith({
|
expect(db.assignment.delete).toHaveBeenCalledWith({
|
||||||
where: { id: "assignment_1" },
|
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 type { Prisma, PrismaClient } from "@planarchy/db";
|
||||||
|
import { AllocationStatus } from "@planarchy/shared";
|
||||||
|
|
||||||
type DbClient =
|
type DbClient =
|
||||||
| Pick<PrismaClient, "assignment">
|
| Pick<PrismaClient, "assignment" | "demandRequirement">
|
||||||
| Pick<Prisma.TransactionClient, "assignment">;
|
| Pick<Prisma.TransactionClient, "assignment" | "demandRequirement">;
|
||||||
|
|
||||||
export interface DeleteAssignmentResult {
|
export interface DeleteAssignmentResult {
|
||||||
deletedId: string;
|
deletedId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
|
reopenedDemandId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAssignment(
|
export async function deleteAssignment(
|
||||||
@@ -20,6 +22,7 @@ export async function deleteAssignment(
|
|||||||
id: true,
|
id: true,
|
||||||
projectId: true,
|
projectId: true,
|
||||||
resourceId: true,
|
resourceId: true,
|
||||||
|
demandRequirementId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,9 +34,31 @@ export async function deleteAssignment(
|
|||||||
where: { id: assignment.id },
|
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 {
|
return {
|
||||||
deletedId: assignment.id,
|
deletedId: assignment.id,
|
||||||
projectId: assignment.projectId,
|
projectId: assignment.projectId,
|
||||||
resourceId: assignment.resourceId,
|
resourceId: assignment.resourceId,
|
||||||
|
reopenedDemandId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ model Project {
|
|||||||
endDate DateTime @db.Date
|
endDate DateTime @db.Date
|
||||||
status ProjectStatus @default(DRAFT)
|
status ProjectStatus @default(DRAFT)
|
||||||
responsiblePerson String?
|
responsiblePerson String?
|
||||||
|
color String? // Hex color for timeline display, e.g. "#3b82f6"
|
||||||
|
|
||||||
// staffingReqs: StaffingRequirement[]
|
// staffingReqs: StaffingRequirement[]
|
||||||
staffingReqs Json @db.JsonB @default("[]")
|
staffingReqs Json @db.JsonB @default("[]")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const CreateProjectBaseSchema = z.object({
|
|||||||
blueprintId: z.string().optional(),
|
blueprintId: z.string().optional(),
|
||||||
status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT),
|
status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT),
|
||||||
responsiblePerson: z.string().max(200).optional(),
|
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(),
|
utilizationCategoryId: z.string().optional(),
|
||||||
clientId: z.string().optional(),
|
clientId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+274
@@ -80,6 +80,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4(react@19.2.4)
|
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:
|
react-grid-layout:
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -92,6 +95,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.1
|
version: 2.6.1
|
||||||
|
three:
|
||||||
|
specifier: ^0.183.2
|
||||||
|
version: 0.183.2
|
||||||
xlsx:
|
xlsx:
|
||||||
specifier: ^0.18.5
|
specifier: ^0.18.5
|
||||||
version: 0.18.5
|
version: 0.18.5
|
||||||
@@ -117,6 +123,9 @@ importers:
|
|||||||
'@types/react-grid-layout':
|
'@types/react-grid-layout':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
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:
|
autoprefixer:
|
||||||
specifier: ^10.4.20
|
specifier: ^10.4.20
|
||||||
version: 10.4.27(postcss@8.5.8)
|
version: 10.4.27(postcss@8.5.8)
|
||||||
@@ -353,6 +362,10 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
3d-force-graph@1.79.1:
|
||||||
|
resolution: {integrity: sha512-iscIVt4jWjJ11KEEswgOIOWk8Ew4EFKHRyERJXJ0ouycqzHCtWwb9E5imnxS5rYF1f1IESkFNAfB+h3EkU0Irw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0':
|
'@alloc/quick-lru@5.2.0':
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -375,6 +388,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
'@emnapi/core@1.8.1':
|
'@emnapi/core@1.8.1':
|
||||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||||
|
|
||||||
@@ -1304,6 +1320,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=5.7.2'
|
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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -1364,9 +1386,18 @@ packages:
|
|||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
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':
|
'@types/use-sync-external-store@0.0.6':
|
||||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
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':
|
'@typescript-eslint/eslint-plugin@8.56.1':
|
||||||
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
|
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1455,9 +1486,16 @@ packages:
|
|||||||
'@vitest/utils@2.1.9':
|
'@vitest/utils@2.1.9':
|
||||||
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
|
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:
|
abs-svg-path@0.1.1:
|
||||||
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||||
|
|
||||||
|
accessor-fn@1.5.3:
|
||||||
|
resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1749,14 +1787,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-binarytree@1.0.2:
|
||||||
|
resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==}
|
||||||
|
|
||||||
d3-color@3.1.0:
|
d3-color@3.1.0:
|
||||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-ease@3.0.1:
|
d3-ease@3.0.1:
|
||||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-force-3d@3.0.6:
|
||||||
|
resolution: {integrity: sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-format@3.1.2:
|
d3-format@3.1.2:
|
||||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1765,14 +1814,29 @@ packages:
|
|||||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-octree@1.1.0:
|
||||||
|
resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==}
|
||||||
|
|
||||||
d3-path@3.1.0:
|
d3-path@3.1.0:
|
||||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
engines: {node: '>=12'}
|
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:
|
d3-scale@4.0.2:
|
||||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1789,6 +1853,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
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:
|
data-view-buffer@1.0.2:
|
||||||
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
|
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2064,6 +2132,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fflate@0.8.2:
|
||||||
|
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -2083,6 +2154,10 @@ packages:
|
|||||||
flatted@3.3.4:
|
flatted@3.3.4:
|
||||||
resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==}
|
resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==}
|
||||||
|
|
||||||
|
float-tooltip@1.7.5:
|
||||||
|
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
fontkit@2.0.4:
|
fontkit@2.0.4:
|
||||||
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
||||||
|
|
||||||
@@ -2379,6 +2454,10 @@ packages:
|
|||||||
jay-peg@1.1.1:
|
jay-peg@1.1.1:
|
||||||
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
|
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:
|
jiti@1.21.7:
|
||||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2409,6 +2488,10 @@ packages:
|
|||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
|
kapsule@1.16.3:
|
||||||
|
resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -2440,6 +2523,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash-es@4.17.23:
|
||||||
|
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
|
||||||
|
|
||||||
lodash.defaults@4.2.0:
|
lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
@@ -2507,6 +2593,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
meshoptimizer@1.0.1:
|
||||||
|
resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
@@ -2580,6 +2669,21 @@ packages:
|
|||||||
sass:
|
sass:
|
||||||
optional: true
|
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:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
@@ -2724,6 +2828,10 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
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:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2828,6 +2936,12 @@ packages:
|
|||||||
react: '>= 16.3.0'
|
react: '>= 16.3.0'
|
||||||
react-dom: '>= 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:
|
react-grid-layout@2.2.2:
|
||||||
resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==}
|
resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2837,6 +2951,12 @@ packages:
|
|||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
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:
|
react-redux@9.2.0:
|
||||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3131,6 +3251,21 @@ packages:
|
|||||||
thenify@3.3.1:
|
thenify@3.3.1:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
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:
|
tiny-inflate@1.0.3:
|
||||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||||
|
|
||||||
@@ -3140,6 +3275,9 @@ packages:
|
|||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
tinycolor2@1.6.0:
|
||||||
|
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
||||||
|
|
||||||
tinyexec@0.3.2:
|
tinyexec@0.3.2:
|
||||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
@@ -3419,6 +3557,14 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
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': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
'@auth/core@0.41.0':
|
'@auth/core@0.41.0':
|
||||||
@@ -3431,6 +3577,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/runtime@7.28.6': {}
|
'@babel/runtime@7.28.6': {}
|
||||||
|
|
||||||
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
'@emnapi/core@1.8.1':
|
'@emnapi/core@1.8.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.1.0
|
'@emnapi/wasi-threads': 1.1.0
|
||||||
@@ -4150,6 +4298,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@25.0.0': {}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -4210,8 +4362,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
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/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)':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -4343,8 +4509,12 @@ snapshots:
|
|||||||
loupe: 3.2.1
|
loupe: 3.2.1
|
||||||
tinyrainbow: 1.2.0
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
|
'@webgpu/types@0.1.69': {}
|
||||||
|
|
||||||
abs-svg-path@0.1.1: {}
|
abs-svg-path@0.1.1: {}
|
||||||
|
|
||||||
|
accessor-fn@1.5.3: {}
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.16.0):
|
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -4673,18 +4843,39 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
internmap: 2.0.3
|
internmap: 2.0.3
|
||||||
|
|
||||||
|
d3-binarytree@1.0.2: {}
|
||||||
|
|
||||||
d3-color@3.1.0: {}
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
d3-ease@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-format@3.1.2: {}
|
||||||
|
|
||||||
d3-interpolate@3.0.1:
|
d3-interpolate@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-color: 3.1.0
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-octree@1.1.0: {}
|
||||||
|
|
||||||
d3-path@3.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:
|
d3-scale@4.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-array: 3.2.4
|
d3-array: 3.2.4
|
||||||
@@ -4693,6 +4884,8 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-time-format: 4.1.0
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-path: 3.1.0
|
d3-path: 3.1.0
|
||||||
@@ -4707,6 +4900,10 @@ snapshots:
|
|||||||
|
|
||||||
d3-timer@3.0.1: {}
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
data-bind-mapper@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
accessor-fn: 1.5.3
|
||||||
|
|
||||||
data-view-buffer@1.0.2:
|
data-view-buffer@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -5103,6 +5300,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
@@ -5123,6 +5322,12 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.3.4: {}
|
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:
|
fontkit@2.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
@@ -5435,6 +5640,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
restructure: 3.0.2
|
restructure: 3.0.2
|
||||||
|
|
||||||
|
jerrypick@1.1.2: {}
|
||||||
|
|
||||||
jiti@1.21.7: {}
|
jiti@1.21.7: {}
|
||||||
|
|
||||||
jose@6.1.3: {}
|
jose@6.1.3: {}
|
||||||
@@ -5462,6 +5669,10 @@ snapshots:
|
|||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
setimmediate: 1.0.5
|
setimmediate: 1.0.5
|
||||||
|
|
||||||
|
kapsule@1.16.3:
|
||||||
|
dependencies:
|
||||||
|
lodash-es: 4.17.23
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -5494,6 +5705,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash-es@4.17.23: {}
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
lodash.difference@4.5.0: {}
|
lodash.difference@4.5.0: {}
|
||||||
@@ -5540,6 +5753,8 @@ snapshots:
|
|||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
|
meshoptimizer@1.0.1: {}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
@@ -5605,6 +5820,22 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- 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: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
nodemailer@8.0.1: {}
|
nodemailer@8.0.1: {}
|
||||||
@@ -5725,6 +5956,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
polished@4.3.1:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss-import@15.1.0(postcss@8.5.8):
|
postcss-import@15.1.0(postcss@8.5.8):
|
||||||
@@ -5815,6 +6050,13 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(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):
|
react-grid-layout@2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@@ -5828,6 +6070,11 @@ snapshots:
|
|||||||
|
|
||||||
react-is@16.13.1: {}
|
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):
|
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/use-sync-external-store': 0.0.6
|
'@types/use-sync-external-store': 0.0.6
|
||||||
@@ -6237,12 +6484,39 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
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-inflate@1.0.3: {}
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinycolor2@1.6.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
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
|
## 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
|
## Quality Gates
|
||||||
|
|
||||||
| Gate | Status | Details |
|
| Gate | Status | Details |
|
||||||
|------|--------|---------|
|
|------|--------|---------|
|
||||||
| Engine Tests | ✅ | 254/254 bestanden (17 Dateien) |
|
| Engine Tests | ✅ | 283/283 (19 files) |
|
||||||
| Staffing Tests | ✅ | 37/37 bestanden (3 Dateien) |
|
| Staffing Tests | ✅ | 37/37 (3 files) |
|
||||||
| API Tests | ✅ | 209/209 bestanden (21 Dateien) |
|
| API Tests | ✅ | 209/209 (21 files) |
|
||||||
| Application Tests | ✅ | 67/67 bestanden (15 Dateien) |
|
| Application Tests | ✅ | 67/67 (15 files) |
|
||||||
| **Gesamt** | **✅** | **567/567 Tests bestanden** |
|
| TypeScript (web) | ✅ | 0 errors (excl. BlueprintFieldEditor TS2589) |
|
||||||
| TypeScript (web) | ✅ | 0 Fehler |
|
| TypeScript (api) | ✅ | 0 errors |
|
||||||
| TypeScript (api) | ✅ | 0 Fehler |
|
|
||||||
| Paketabhaengigkeiten | ✅ | Keine Zyklen |
|
|
||||||
|
|
||||||
## Architektur-Checkliste
|
## Code-Review-Checkliste
|
||||||
|
|
||||||
- [x] Keine zirkulaeren Abhaengigkeiten — engine, staffing, ui sind sauber isoliert
|
### Architektur
|
||||||
- [x] `engine` und `staffing` haben keine DB-Imports
|
- [x] Keine zirkulaeren Abhaengigkeiten — `api → engine/shared/db` (erlaubt)
|
||||||
- [x] Alle 24 tRPC-Router in `packages/api/src/router/index.ts` registriert
|
- [x] `engine` und `staffing` unveraendert, keine DB-Imports
|
||||||
- [x] SSE-Events: 10 Emitter fuer Allocation, Project, Budget, Vacation, Role, Notification
|
- [x] Neuer Router `computationGraph` in `index.ts` registriert
|
||||||
- [x] SSE-Debouncing (50ms) aktiv in event-bus.ts
|
- [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
|
### Datenbank & Prisma
|
||||||
- [x] Prisma-Enums an Client-Grenzen mit `as unknown as SharedType` gecastet
|
- [x] Keine Schema-Aenderungen — rein lesende Queries
|
||||||
- [x] JSONB-Felder korrekt gecastet
|
- [x] Geldbetraege in Integer-Cents: `lcrCents`, `dailyCostCents`, `budgetCents` etc.
|
||||||
- [x] Nullable FKs mit optional chaining behandelt
|
- [x] Kein Seed noetig (kein neues Modell)
|
||||||
- [x] `exactOptionalPropertyTypes` Pattern eingehalten (Spread statt `{ key: undefined }`)
|
|
||||||
|
|
||||||
## 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
|
### Sicherheit
|
||||||
- [x] Kein unsicheres Raw-SQL in App-Routern — einziges `$executeRaw` in resource.ts nutzt tagged template literals (parameterisiert)
|
- [x] Beide Procedures nutzen `controllerProcedure` (ADMIN + MANAGER + CONTROLLER)
|
||||||
- [x] Composite Indexes fuer Assignment, DemandRequirement, Vacation vorhanden
|
- [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)
|
### Kritisch
|
||||||
- [x] Vacation create/cancel: Ownership-Check fuer USER-Rolle
|
Keine.
|
||||||
- [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
|
|
||||||
|
|
||||||
## UI & Komponenten
|
### Minor
|
||||||
|
|
||||||
- [x] AppShell: Kollabierbare Navigationsgruppen (Estimating, ACN-Orga)
|
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.
|
||||||
- [x] Tailwind-Opacity nur in 5er-Schritten (CSS-Fix fuer `/92`, `/88`, `/94`)
|
|
||||||
- [x] `trpc.role.list` korrekt als Array behandelt
|
|
||||||
|
|
||||||
## 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
|
### Empfehlungen
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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
|
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.
|
||||||
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
|
|
||||||
|
|
||||||
## Learnings-Vorschlag fuer LEARNINGS.md
|
## Learnings-Vorschlag fuer LEARNINGS.md
|
||||||
|
|
||||||
```
|
```markdown
|
||||||
### Security: protectedProcedure muss dbUser pruefen (2026-03-15)
|
### react-force-graph-3d in Next.js 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.
|
|
||||||
|
|
||||||
### Security: IDOR-Checks bei Self-Service-Endpunkten (2026-03-15)
|
- Muss als `dynamic(() => import("react-force-graph-3d"), { ssr: false })` geladen werden
|
||||||
Vacation create/cancel und Entitlement getBalance hatten keine Ownership-Checks.
|
- React 19 Kompatibilitaet: funktioniert, aber TypeScript-Generics sind loose — `as any` Cast + eslint-disable noetig bei Callbacks (`onNodeClick`, `nodeThreeObject`, `linkColor`)
|
||||||
USER-Rolle konnte Aktionen fuer beliebige Ressourcen ausfuehren.
|
- Node-Rendering via Canvas-basierte Three.js Sprites (nicht HTML-Overlays) — performanter bei 50+ Knoten
|
||||||
Pattern: Bei `protectedProcedure`-Endpunkten die eine `resourceId` akzeptieren,
|
- `warmupTicks={50}` + `cooldownTicks={0}` verhindert die Kraft-Simulation und nutzt stattdessen die fixen `fx/fy/fz` Positionen
|
||||||
immer `resource.userId === ctx.dbUser.id` pruefen fuer nicht-privilegierte Rollen.
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user