mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- Add API modules: entity, partnership, distribution, k-document, family-office, upload - Add client pages: entities, partnerships, entity-detail, partnership-detail, distributions, k-documents, family-dashboard - Add CRUD dialogs for ownerships, members, assets, valuations, distributions, k-documents - Add navigation items for Entities, Partnerships, Distributions, K-1 Documents - Fix empty accounts dropdown in ownership dialog - Add Prisma schema models and migrations for family office entities - Add speckit scaffolding for spec-driven developmentpull/6603/head
141 changed files with 17676 additions and 11 deletions
@ -0,0 +1,30 @@ |
|||||
|
# portfolio-management Development Guidelines |
||||
|
|
||||
|
Auto-generated from all feature plans. Last updated: 2026-03-15 |
||||
|
|
||||
|
## Active Technologies |
||||
|
|
||||
|
- TypeScript 5.9.2, Node.js ≥22.18.0 + NestJS 11.1.14 (API), Angular 21.1.1 + Angular Material 21.1.1 (client), Prisma 6.19.0 (ORM), Nx 22.5.3 (monorepo), big.js (decimal math), date-fns 4.1.0, chart.js 4.5.1, Bull 4.16.5 (job queues), Redis (caching), yahoo-finance2 3.13.2 (001-family-office-transform) |
||||
|
|
||||
|
## Project Structure |
||||
|
|
||||
|
```text |
||||
|
backend/ |
||||
|
frontend/ |
||||
|
tests/ |
||||
|
``` |
||||
|
|
||||
|
## Commands |
||||
|
|
||||
|
npm test; npm run lint |
||||
|
|
||||
|
## Code Style |
||||
|
|
||||
|
TypeScript 5.9.2, Node.js ≥22.18.0: Follow standard conventions |
||||
|
|
||||
|
## Recent Changes |
||||
|
|
||||
|
- 001-family-office-transform: Added TypeScript 5.9.2, Node.js ≥22.18.0 + NestJS 11.1.14 (API), Angular 21.1.1 + Angular Material 21.1.1 (client), Prisma 6.19.0 (ORM), Nx 22.5.3 (monorepo), big.js (decimal math), date-fns 4.1.0, chart.js 4.5.1, Bull 4.16.5 (job queues), Redis (caching), yahoo-finance2 3.13.2 |
||||
|
|
||||
|
<!-- MANUAL ADDITIONS START --> |
||||
|
<!-- MANUAL ADDITIONS END --> |
||||
@ -0,0 +1,184 @@ |
|||||
|
--- |
||||
|
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. |
||||
|
--- |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Goal |
||||
|
|
||||
|
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`. |
||||
|
|
||||
|
## Operating Constraints |
||||
|
|
||||
|
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually). |
||||
|
|
||||
|
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`. |
||||
|
|
||||
|
## Execution Steps |
||||
|
|
||||
|
### 1. Initialize Analysis Context |
||||
|
|
||||
|
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths: |
||||
|
|
||||
|
- SPEC = FEATURE_DIR/spec.md |
||||
|
- PLAN = FEATURE_DIR/plan.md |
||||
|
- TASKS = FEATURE_DIR/tasks.md |
||||
|
|
||||
|
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command). |
||||
|
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). |
||||
|
|
||||
|
### 2. Load Artifacts (Progressive Disclosure) |
||||
|
|
||||
|
Load only the minimal necessary context from each artifact: |
||||
|
|
||||
|
**From spec.md:** |
||||
|
|
||||
|
- Overview/Context |
||||
|
- Functional Requirements |
||||
|
- Non-Functional Requirements |
||||
|
- User Stories |
||||
|
- Edge Cases (if present) |
||||
|
|
||||
|
**From plan.md:** |
||||
|
|
||||
|
- Architecture/stack choices |
||||
|
- Data Model references |
||||
|
- Phases |
||||
|
- Technical constraints |
||||
|
|
||||
|
**From tasks.md:** |
||||
|
|
||||
|
- Task IDs |
||||
|
- Descriptions |
||||
|
- Phase grouping |
||||
|
- Parallel markers [P] |
||||
|
- Referenced file paths |
||||
|
|
||||
|
**From constitution:** |
||||
|
|
||||
|
- Load `.specify/memory/constitution.md` for principle validation |
||||
|
|
||||
|
### 3. Build Semantic Models |
||||
|
|
||||
|
Create internal representations (do not include raw artifacts in output): |
||||
|
|
||||
|
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`) |
||||
|
- **User story/action inventory**: Discrete user actions with acceptance criteria |
||||
|
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases) |
||||
|
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements |
||||
|
|
||||
|
### 4. Detection Passes (Token-Efficient Analysis) |
||||
|
|
||||
|
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary. |
||||
|
|
||||
|
#### A. Duplication Detection |
||||
|
|
||||
|
- Identify near-duplicate requirements |
||||
|
- Mark lower-quality phrasing for consolidation |
||||
|
|
||||
|
#### B. Ambiguity Detection |
||||
|
|
||||
|
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria |
||||
|
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.) |
||||
|
|
||||
|
#### C. Underspecification |
||||
|
|
||||
|
- Requirements with verbs but missing object or measurable outcome |
||||
|
- User stories missing acceptance criteria alignment |
||||
|
- Tasks referencing files or components not defined in spec/plan |
||||
|
|
||||
|
#### D. Constitution Alignment |
||||
|
|
||||
|
- Any requirement or plan element conflicting with a MUST principle |
||||
|
- Missing mandated sections or quality gates from constitution |
||||
|
|
||||
|
#### E. Coverage Gaps |
||||
|
|
||||
|
- Requirements with zero associated tasks |
||||
|
- Tasks with no mapped requirement/story |
||||
|
- Non-functional requirements not reflected in tasks (e.g., performance, security) |
||||
|
|
||||
|
#### F. Inconsistency |
||||
|
|
||||
|
- Terminology drift (same concept named differently across files) |
||||
|
- Data entities referenced in plan but absent in spec (or vice versa) |
||||
|
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note) |
||||
|
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue) |
||||
|
|
||||
|
### 5. Severity Assignment |
||||
|
|
||||
|
Use this heuristic to prioritize findings: |
||||
|
|
||||
|
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality |
||||
|
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion |
||||
|
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case |
||||
|
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order |
||||
|
|
||||
|
### 6. Produce Compact Analysis Report |
||||
|
|
||||
|
Output a Markdown report (no file writes) with the following structure: |
||||
|
|
||||
|
## Specification Analysis Report |
||||
|
|
||||
|
| ID | Category | Severity | Location(s) | Summary | Recommendation | |
||||
|
| --- | ----------- | -------- | ---------------- | ---------------------------- | ------------------------------------ | |
||||
|
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version | |
||||
|
|
||||
|
(Add one row per finding; generate stable IDs prefixed by category initial.) |
||||
|
|
||||
|
**Coverage Summary Table:** |
||||
|
|
||||
|
| Requirement Key | Has Task? | Task IDs | Notes | |
||||
|
| --------------- | --------- | -------- | ----- | |
||||
|
|
||||
|
**Constitution Alignment Issues:** (if any) |
||||
|
|
||||
|
**Unmapped Tasks:** (if any) |
||||
|
|
||||
|
**Metrics:** |
||||
|
|
||||
|
- Total Requirements |
||||
|
- Total Tasks |
||||
|
- Coverage % (requirements with >=1 task) |
||||
|
- Ambiguity Count |
||||
|
- Duplication Count |
||||
|
- Critical Issues Count |
||||
|
|
||||
|
### 7. Provide Next Actions |
||||
|
|
||||
|
At end of report, output a concise Next Actions block: |
||||
|
|
||||
|
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement` |
||||
|
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions |
||||
|
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'" |
||||
|
|
||||
|
### 8. Offer Remediation |
||||
|
|
||||
|
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.) |
||||
|
|
||||
|
## Operating Principles |
||||
|
|
||||
|
### Context Efficiency |
||||
|
|
||||
|
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation |
||||
|
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis |
||||
|
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow |
||||
|
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts |
||||
|
|
||||
|
### Analysis Guidelines |
||||
|
|
||||
|
- **NEVER modify files** (this is read-only analysis) |
||||
|
- **NEVER hallucinate missing sections** (if absent, report them accurately) |
||||
|
- **Prioritize constitution violations** (these are always CRITICAL) |
||||
|
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns) |
||||
|
- **Report zero issues gracefully** (emit success report with coverage statistics) |
||||
|
|
||||
|
## Context |
||||
|
|
||||
|
$ARGUMENTS |
||||
@ -0,0 +1,295 @@ |
|||||
|
--- |
||||
|
description: Generate a custom checklist for the current feature based on user requirements. |
||||
|
--- |
||||
|
|
||||
|
## Checklist Purpose: "Unit Tests for English" |
||||
|
|
||||
|
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain. |
||||
|
|
||||
|
**NOT for verification/testing**: |
||||
|
|
||||
|
- ❌ NOT "Verify the button clicks correctly" |
||||
|
- ❌ NOT "Test error handling works" |
||||
|
- ❌ NOT "Confirm the API returns 200" |
||||
|
- ❌ NOT checking if code/implementation matches the spec |
||||
|
|
||||
|
**FOR requirements quality validation**: |
||||
|
|
||||
|
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness) |
||||
|
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity) |
||||
|
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency) |
||||
|
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage) |
||||
|
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases) |
||||
|
|
||||
|
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works. |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Execution Steps |
||||
|
|
||||
|
1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list. |
||||
|
- All file paths must be absolute. |
||||
|
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). |
||||
|
|
||||
|
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST: |
||||
|
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks |
||||
|
- Only ask about information that materially changes checklist content |
||||
|
- Be skipped individually if already unambiguous in `$ARGUMENTS` |
||||
|
- Prefer precision over breadth |
||||
|
|
||||
|
Generation algorithm: |
||||
|
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts"). |
||||
|
2. Cluster signals into candidate focus areas (max 4) ranked by relevance. |
||||
|
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit. |
||||
|
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria. |
||||
|
5. Formulate questions chosen from these archetypes: |
||||
|
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?") |
||||
|
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?") |
||||
|
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?") |
||||
|
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?") |
||||
|
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?") |
||||
|
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?") |
||||
|
|
||||
|
Question formatting rules: |
||||
|
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters |
||||
|
- Limit to A–E options maximum; omit table if a free-form answer is clearer |
||||
|
- Never ask the user to restate what they already said |
||||
|
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope." |
||||
|
|
||||
|
Defaults when interaction impossible: |
||||
|
- Depth: Standard |
||||
|
- Audience: Reviewer (PR) if code-related; Author otherwise |
||||
|
- Focus: Top 2 relevance clusters |
||||
|
|
||||
|
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more. |
||||
|
|
||||
|
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers: |
||||
|
- Derive checklist theme (e.g., security, review, deploy, ux) |
||||
|
- Consolidate explicit must-have items mentioned by user |
||||
|
- Map focus selections to category scaffolding |
||||
|
- Infer any missing context from spec/plan/tasks (do NOT hallucinate) |
||||
|
|
||||
|
4. **Load feature context**: Read from FEATURE_DIR: |
||||
|
- spec.md: Feature requirements and scope |
||||
|
- plan.md (if exists): Technical details, dependencies |
||||
|
- tasks.md (if exists): Implementation tasks |
||||
|
|
||||
|
**Context Loading Strategy**: |
||||
|
- Load only necessary portions relevant to active focus areas (avoid full-file dumping) |
||||
|
- Prefer summarizing long sections into concise scenario/requirement bullets |
||||
|
- Use progressive disclosure: add follow-on retrieval only if gaps detected |
||||
|
- If source docs are large, generate interim summary items instead of embedding raw text |
||||
|
|
||||
|
5. **Generate checklist** - Create "Unit Tests for Requirements": |
||||
|
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist |
||||
|
- Generate unique checklist filename: |
||||
|
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`) |
||||
|
- Format: `[domain].md` |
||||
|
- File handling behavior: |
||||
|
- If file does NOT exist: Create new file and number items starting from CHK001 |
||||
|
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016) |
||||
|
- Never delete or replace existing checklist content - always preserve and append |
||||
|
|
||||
|
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**: |
||||
|
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for: |
||||
|
- **Completeness**: Are all necessary requirements present? |
||||
|
- **Clarity**: Are requirements unambiguous and specific? |
||||
|
- **Consistency**: Do requirements align with each other? |
||||
|
- **Measurability**: Can requirements be objectively verified? |
||||
|
- **Coverage**: Are all scenarios/edge cases addressed? |
||||
|
|
||||
|
**Category Structure** - Group items by requirement quality dimensions: |
||||
|
- **Requirement Completeness** (Are all necessary requirements documented?) |
||||
|
- **Requirement Clarity** (Are requirements specific and unambiguous?) |
||||
|
- **Requirement Consistency** (Do requirements align without conflicts?) |
||||
|
- **Acceptance Criteria Quality** (Are success criteria measurable?) |
||||
|
- **Scenario Coverage** (Are all flows/cases addressed?) |
||||
|
- **Edge Case Coverage** (Are boundary conditions defined?) |
||||
|
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?) |
||||
|
- **Dependencies & Assumptions** (Are they documented and validated?) |
||||
|
- **Ambiguities & Conflicts** (What needs clarification?) |
||||
|
|
||||
|
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**: |
||||
|
|
||||
|
❌ **WRONG** (Testing implementation): |
||||
|
- "Verify landing page displays 3 episode cards" |
||||
|
- "Test hover states work on desktop" |
||||
|
- "Confirm logo click navigates home" |
||||
|
|
||||
|
✅ **CORRECT** (Testing requirements quality): |
||||
|
- "Are the exact number and layout of featured episodes specified?" [Completeness] |
||||
|
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity] |
||||
|
- "Are hover state requirements consistent across all interactive elements?" [Consistency] |
||||
|
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage] |
||||
|
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases] |
||||
|
- "Are loading states defined for asynchronous episode data?" [Completeness] |
||||
|
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity] |
||||
|
|
||||
|
**ITEM STRUCTURE**: |
||||
|
Each item should follow this pattern: |
||||
|
- Question format asking about requirement quality |
||||
|
- Focus on what's WRITTEN (or not written) in the spec/plan |
||||
|
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.] |
||||
|
- Reference spec section `[Spec §X.Y]` when checking existing requirements |
||||
|
- Use `[Gap]` marker when checking for missing requirements |
||||
|
|
||||
|
**EXAMPLES BY QUALITY DIMENSION**: |
||||
|
|
||||
|
Completeness: |
||||
|
- "Are error handling requirements defined for all API failure modes? [Gap]" |
||||
|
- "Are accessibility requirements specified for all interactive elements? [Completeness]" |
||||
|
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]" |
||||
|
|
||||
|
Clarity: |
||||
|
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]" |
||||
|
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]" |
||||
|
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]" |
||||
|
|
||||
|
Consistency: |
||||
|
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]" |
||||
|
- "Are card component requirements consistent between landing and detail pages? [Consistency]" |
||||
|
|
||||
|
Coverage: |
||||
|
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]" |
||||
|
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]" |
||||
|
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]" |
||||
|
|
||||
|
Measurability: |
||||
|
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]" |
||||
|
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]" |
||||
|
|
||||
|
**Scenario Classification & Coverage** (Requirements Quality Focus): |
||||
|
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios |
||||
|
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?" |
||||
|
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]" |
||||
|
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]" |
||||
|
|
||||
|
**Traceability Requirements**: |
||||
|
- MINIMUM: ≥80% of items MUST include at least one traceability reference |
||||
|
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]` |
||||
|
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]" |
||||
|
|
||||
|
**Surface & Resolve Issues** (Requirements Quality Problems): |
||||
|
Ask questions about the requirements themselves: |
||||
|
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]" |
||||
|
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]" |
||||
|
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]" |
||||
|
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]" |
||||
|
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]" |
||||
|
|
||||
|
**Content Consolidation**: |
||||
|
- Soft cap: If raw candidate items > 40, prioritize by risk/impact |
||||
|
- Merge near-duplicates checking the same requirement aspect |
||||
|
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]" |
||||
|
|
||||
|
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test: |
||||
|
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior |
||||
|
- ❌ References to code execution, user actions, system behavior |
||||
|
- ❌ "Displays correctly", "works properly", "functions as expected" |
||||
|
- ❌ "Click", "navigate", "render", "load", "execute" |
||||
|
- ❌ Test cases, test plans, QA procedures |
||||
|
- ❌ Implementation details (frameworks, APIs, algorithms) |
||||
|
|
||||
|
**✅ REQUIRED PATTERNS** - These test requirements quality: |
||||
|
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?" |
||||
|
- ✅ "Is [vague term] quantified/clarified with specific criteria?" |
||||
|
- ✅ "Are requirements consistent between [section A] and [section B]?" |
||||
|
- ✅ "Can [requirement] be objectively measured/verified?" |
||||
|
- ✅ "Are [edge cases/scenarios] addressed in requirements?" |
||||
|
- ✅ "Does the spec define [missing aspect]?" |
||||
|
|
||||
|
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001. |
||||
|
|
||||
|
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize: |
||||
|
- Focus areas selected |
||||
|
- Depth level |
||||
|
- Actor/timing |
||||
|
- Any explicit user-specified must-have items incorporated |
||||
|
|
||||
|
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows: |
||||
|
|
||||
|
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`) |
||||
|
- Simple, memorable filenames that indicate checklist purpose |
||||
|
- Easy identification and navigation in the `checklists/` folder |
||||
|
|
||||
|
To avoid clutter, use descriptive types and clean up obsolete checklists when done. |
||||
|
|
||||
|
## Example Checklist Types & Sample Items |
||||
|
|
||||
|
**UX Requirements Quality:** `ux.md` |
||||
|
|
||||
|
Sample items (testing the requirements, NOT the implementation): |
||||
|
|
||||
|
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]" |
||||
|
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]" |
||||
|
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]" |
||||
|
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]" |
||||
|
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]" |
||||
|
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]" |
||||
|
|
||||
|
**API Requirements Quality:** `api.md` |
||||
|
|
||||
|
Sample items: |
||||
|
|
||||
|
- "Are error response formats specified for all failure scenarios? [Completeness]" |
||||
|
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]" |
||||
|
- "Are authentication requirements consistent across all endpoints? [Consistency]" |
||||
|
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]" |
||||
|
- "Is versioning strategy documented in requirements? [Gap]" |
||||
|
|
||||
|
**Performance Requirements Quality:** `performance.md` |
||||
|
|
||||
|
Sample items: |
||||
|
|
||||
|
- "Are performance requirements quantified with specific metrics? [Clarity]" |
||||
|
- "Are performance targets defined for all critical user journeys? [Coverage]" |
||||
|
- "Are performance requirements under different load conditions specified? [Completeness]" |
||||
|
- "Can performance requirements be objectively measured? [Measurability]" |
||||
|
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]" |
||||
|
|
||||
|
**Security Requirements Quality:** `security.md` |
||||
|
|
||||
|
Sample items: |
||||
|
|
||||
|
- "Are authentication requirements specified for all protected resources? [Coverage]" |
||||
|
- "Are data protection requirements defined for sensitive information? [Completeness]" |
||||
|
- "Is the threat model documented and requirements aligned to it? [Traceability]" |
||||
|
- "Are security requirements consistent with compliance obligations? [Consistency]" |
||||
|
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]" |
||||
|
|
||||
|
## Anti-Examples: What NOT To Do |
||||
|
|
||||
|
**❌ WRONG - These test implementation, not requirements:** |
||||
|
|
||||
|
```markdown |
||||
|
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001] |
||||
|
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003] |
||||
|
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010] |
||||
|
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005] |
||||
|
``` |
||||
|
|
||||
|
**✅ CORRECT - These test requirements quality:** |
||||
|
|
||||
|
```markdown |
||||
|
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001] |
||||
|
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003] |
||||
|
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010] |
||||
|
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005] |
||||
|
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap] |
||||
|
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001] |
||||
|
``` |
||||
|
|
||||
|
**Key Differences:** |
||||
|
|
||||
|
- Wrong: Tests if the system works correctly |
||||
|
- Correct: Tests if the requirements are written correctly |
||||
|
- Wrong: Verification of behavior |
||||
|
- Correct: Validation of requirement quality |
||||
|
- Wrong: "Does it do X?" |
||||
|
- Correct: "Is X clearly specified?" |
||||
@ -0,0 +1,181 @@ |
|||||
|
--- |
||||
|
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec. |
||||
|
handoffs: |
||||
|
- label: Build Technical Plan |
||||
|
agent: speckit.plan |
||||
|
prompt: Create a plan for the spec. I am building with... |
||||
|
--- |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Outline |
||||
|
|
||||
|
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file. |
||||
|
|
||||
|
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases. |
||||
|
|
||||
|
Execution steps: |
||||
|
|
||||
|
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields: |
||||
|
- `FEATURE_DIR` |
||||
|
- `FEATURE_SPEC` |
||||
|
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.) |
||||
|
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment. |
||||
|
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). |
||||
|
|
||||
|
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked). |
||||
|
|
||||
|
Functional Scope & Behavior: |
||||
|
- Core user goals & success criteria |
||||
|
- Explicit out-of-scope declarations |
||||
|
- User roles / personas differentiation |
||||
|
|
||||
|
Domain & Data Model: |
||||
|
- Entities, attributes, relationships |
||||
|
- Identity & uniqueness rules |
||||
|
- Lifecycle/state transitions |
||||
|
- Data volume / scale assumptions |
||||
|
|
||||
|
Interaction & UX Flow: |
||||
|
- Critical user journeys / sequences |
||||
|
- Error/empty/loading states |
||||
|
- Accessibility or localization notes |
||||
|
|
||||
|
Non-Functional Quality Attributes: |
||||
|
- Performance (latency, throughput targets) |
||||
|
- Scalability (horizontal/vertical, limits) |
||||
|
- Reliability & availability (uptime, recovery expectations) |
||||
|
- Observability (logging, metrics, tracing signals) |
||||
|
- Security & privacy (authN/Z, data protection, threat assumptions) |
||||
|
- Compliance / regulatory constraints (if any) |
||||
|
|
||||
|
Integration & External Dependencies: |
||||
|
- External services/APIs and failure modes |
||||
|
- Data import/export formats |
||||
|
- Protocol/versioning assumptions |
||||
|
|
||||
|
Edge Cases & Failure Handling: |
||||
|
- Negative scenarios |
||||
|
- Rate limiting / throttling |
||||
|
- Conflict resolution (e.g., concurrent edits) |
||||
|
|
||||
|
Constraints & Tradeoffs: |
||||
|
- Technical constraints (language, storage, hosting) |
||||
|
- Explicit tradeoffs or rejected alternatives |
||||
|
|
||||
|
Terminology & Consistency: |
||||
|
- Canonical glossary terms |
||||
|
- Avoided synonyms / deprecated terms |
||||
|
|
||||
|
Completion Signals: |
||||
|
- Acceptance criteria testability |
||||
|
- Measurable Definition of Done style indicators |
||||
|
|
||||
|
Misc / Placeholders: |
||||
|
- TODO markers / unresolved decisions |
||||
|
- Ambiguous adjectives ("robust", "intuitive") lacking quantification |
||||
|
|
||||
|
For each category with Partial or Missing status, add a candidate question opportunity unless: |
||||
|
- Clarification would not materially change implementation or validation strategy |
||||
|
- Information is better deferred to planning phase (note internally) |
||||
|
|
||||
|
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints: |
||||
|
- Maximum of 5 total questions across the whole session. |
||||
|
- Each question must be answerable with EITHER: |
||||
|
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR |
||||
|
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words"). |
||||
|
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation. |
||||
|
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved. |
||||
|
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness). |
||||
|
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests. |
||||
|
- If more than 5 categories remain unresolved, select the top 5 by (Impact \* Uncertainty) heuristic. |
||||
|
|
||||
|
4. Sequential questioning loop (interactive): |
||||
|
- Present EXACTLY ONE question at a time. |
||||
|
- For multiple‑choice questions: |
||||
|
- **Analyze all options** and determine the **most suitable option** based on: |
||||
|
- Best practices for the project type |
||||
|
- Common patterns in similar implementations |
||||
|
- Risk reduction (security, performance, maintainability) |
||||
|
- Alignment with any explicit project goals or constraints visible in the spec |
||||
|
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice). |
||||
|
- Format as: `**Recommended:** Option [X] - <reasoning>` |
||||
|
- Then render all options as a Markdown table: |
||||
|
|
||||
|
| Option | Description | |
||||
|
| ------ | --------------------------------------------------------------------------------------------------- | |
||||
|
| A | <Option A description> | |
||||
|
| B | <Option B description> | |
||||
|
| C | <Option C description> (add D/E as needed up to 5) | |
||||
|
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) | |
||||
|
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.` |
||||
|
|
||||
|
- For short‑answer style (no meaningful discrete options): |
||||
|
- Provide your **suggested answer** based on best practices and context. |
||||
|
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>` |
||||
|
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.` |
||||
|
- After the user answers: |
||||
|
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer. |
||||
|
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint. |
||||
|
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance). |
||||
|
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question. |
||||
|
- Stop asking further questions when: |
||||
|
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR |
||||
|
- User signals completion ("done", "good", "no more"), OR |
||||
|
- You reach 5 asked questions. |
||||
|
- Never reveal future queued questions in advance. |
||||
|
- If no valid questions exist at start, immediately report no critical ambiguities. |
||||
|
|
||||
|
5. Integration after EACH accepted answer (incremental update approach): |
||||
|
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents. |
||||
|
- For the first integrated answer in this session: |
||||
|
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing). |
||||
|
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today. |
||||
|
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`. |
||||
|
- Then immediately apply the clarification to the most appropriate section(s): |
||||
|
- Functional ambiguity → Update or add a bullet in Functional Requirements. |
||||
|
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario. |
||||
|
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly. |
||||
|
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target). |
||||
|
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it). |
||||
|
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once. |
||||
|
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text. |
||||
|
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite). |
||||
|
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact. |
||||
|
- Keep each inserted clarification minimal and testable (avoid narrative drift). |
||||
|
|
||||
|
6. Validation (performed after EACH write plus final pass): |
||||
|
- Clarifications session contains exactly one bullet per accepted answer (no duplicates). |
||||
|
- Total asked (accepted) questions ≤ 5. |
||||
|
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve. |
||||
|
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed). |
||||
|
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`. |
||||
|
- Terminology consistency: same canonical term used across all updated sections. |
||||
|
|
||||
|
7. Write the updated spec back to `FEATURE_SPEC`. |
||||
|
|
||||
|
8. Report completion (after questioning loop ends or early termination): |
||||
|
- Number of questions asked & answered. |
||||
|
- Path to updated spec. |
||||
|
- Sections touched (list names). |
||||
|
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact). |
||||
|
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan. |
||||
|
- Suggested next command. |
||||
|
|
||||
|
Behavior rules: |
||||
|
|
||||
|
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding. |
||||
|
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here). |
||||
|
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions). |
||||
|
- Avoid speculative tech stack questions unless the absence blocks functional clarity. |
||||
|
- Respect user early termination signals ("stop", "done", "proceed"). |
||||
|
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing. |
||||
|
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale. |
||||
|
|
||||
|
Context for prioritization: $ARGUMENTS |
||||
@ -0,0 +1,84 @@ |
|||||
|
--- |
||||
|
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. |
||||
|
handoffs: |
||||
|
- label: Build Specification |
||||
|
agent: speckit.specify |
||||
|
prompt: Implement the feature specification based on the updated constitution. I want to build... |
||||
|
--- |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Outline |
||||
|
|
||||
|
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts. |
||||
|
|
||||
|
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first. |
||||
|
|
||||
|
Follow this execution flow: |
||||
|
|
||||
|
1. Load the existing constitution at `.specify/memory/constitution.md`. |
||||
|
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`. |
||||
|
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly. |
||||
|
|
||||
|
2. Collect/derive values for placeholders: |
||||
|
- If user input (conversation) supplies a value, use it. |
||||
|
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded). |
||||
|
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous. |
||||
|
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules: |
||||
|
- MAJOR: Backward incompatible governance/principle removals or redefinitions. |
||||
|
- MINOR: New principle/section added or materially expanded guidance. |
||||
|
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements. |
||||
|
- If version bump type ambiguous, propose reasoning before finalizing. |
||||
|
|
||||
|
3. Draft the updated constitution content: |
||||
|
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left). |
||||
|
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance. |
||||
|
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious. |
||||
|
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations. |
||||
|
|
||||
|
4. Consistency propagation checklist (convert prior checklist into active validations): |
||||
|
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles. |
||||
|
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints. |
||||
|
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline). |
||||
|
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required. |
||||
|
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed. |
||||
|
|
||||
|
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update): |
||||
|
- Version change: old → new |
||||
|
- List of modified principles (old title → new title if renamed) |
||||
|
- Added sections |
||||
|
- Removed sections |
||||
|
- Templates requiring updates (✅ updated / ⚠ pending) with file paths |
||||
|
- Follow-up TODOs if any placeholders intentionally deferred. |
||||
|
|
||||
|
6. Validation before final output: |
||||
|
- No remaining unexplained bracket tokens. |
||||
|
- Version line matches report. |
||||
|
- Dates ISO format YYYY-MM-DD. |
||||
|
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate). |
||||
|
|
||||
|
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite). |
||||
|
|
||||
|
8. Output a final summary to the user with: |
||||
|
- New version and bump rationale. |
||||
|
- Any files flagged for manual follow-up. |
||||
|
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`). |
||||
|
|
||||
|
Formatting & Style Requirements: |
||||
|
|
||||
|
- Use Markdown headings exactly as in the template (do not demote/promote levels). |
||||
|
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks. |
||||
|
- Keep a single blank line between sections. |
||||
|
- Avoid trailing whitespace. |
||||
|
|
||||
|
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps. |
||||
|
|
||||
|
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items. |
||||
|
|
||||
|
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file. |
||||
@ -0,0 +1,207 @@ |
|||||
|
--- |
||||
|
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md |
||||
|
--- |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Pre-Execution Checks |
||||
|
|
||||
|
**Check for extension hooks (before implementation)**: |
||||
|
|
||||
|
- Check if `.specify/extensions.yml` exists in the project root. |
||||
|
- If it exists, read it and look for entries under the `hooks.before_implement` key |
||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally |
||||
|
- Filter to only hooks where `enabled: true` |
||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: |
||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable |
||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation |
||||
|
- For each executable hook, output the following based on its `optional` flag: |
||||
|
- **Optional hook** (`optional: true`): |
||||
|
|
||||
|
``` |
||||
|
## Extension Hooks |
||||
|
|
||||
|
**Optional Pre-Hook**: {extension} |
||||
|
Command: `/{command}` |
||||
|
Description: {description} |
||||
|
|
||||
|
Prompt: {prompt} |
||||
|
To execute: `/{command}` |
||||
|
``` |
||||
|
|
||||
|
- **Mandatory hook** (`optional: false`): |
||||
|
|
||||
|
``` |
||||
|
## Extension Hooks |
||||
|
|
||||
|
**Automatic Pre-Hook**: {extension} |
||||
|
Executing: `/{command}` |
||||
|
EXECUTE_COMMAND: {command} |
||||
|
|
||||
|
Wait for the result of the hook command before proceeding to the Outline. |
||||
|
``` |
||||
|
|
||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently |
||||
|
|
||||
|
## Outline |
||||
|
|
||||
|
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). |
||||
|
|
||||
|
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists): |
||||
|
- Scan all checklist files in the checklists/ directory |
||||
|
- For each checklist, count: |
||||
|
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]` |
||||
|
- Completed items: Lines matching `- [X]` or `- [x]` |
||||
|
- Incomplete items: Lines matching `- [ ]` |
||||
|
- Create a status table: |
||||
|
|
||||
|
```text |
||||
|
| Checklist | Total | Completed | Incomplete | Status | |
||||
|
|-----------|-------|-----------|------------|--------| |
||||
|
| ux.md | 12 | 12 | 0 | ✓ PASS | |
||||
|
| test.md | 8 | 5 | 3 | ✗ FAIL | |
||||
|
| security.md | 6 | 6 | 0 | ✓ PASS | |
||||
|
``` |
||||
|
|
||||
|
- Calculate overall status: |
||||
|
- **PASS**: All checklists have 0 incomplete items |
||||
|
- **FAIL**: One or more checklists have incomplete items |
||||
|
|
||||
|
- **If any checklist is incomplete**: |
||||
|
- Display the table with incomplete item counts |
||||
|
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)" |
||||
|
- Wait for user response before continuing |
||||
|
- If user says "no" or "wait" or "stop", halt execution |
||||
|
- If user says "yes" or "proceed" or "continue", proceed to step 3 |
||||
|
|
||||
|
- **If all checklists are complete**: |
||||
|
- Display the table showing all checklists passed |
||||
|
- Automatically proceed to step 3 |
||||
|
|
||||
|
3. Load and analyze the implementation context: |
||||
|
- **REQUIRED**: Read tasks.md for the complete task list and execution plan |
||||
|
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure |
||||
|
- **IF EXISTS**: Read data-model.md for entities and relationships |
||||
|
- **IF EXISTS**: Read contracts/ for API specifications and test requirements |
||||
|
- **IF EXISTS**: Read research.md for technical decisions and constraints |
||||
|
- **IF EXISTS**: Read quickstart.md for integration scenarios |
||||
|
|
||||
|
4. **Project Setup Verification**: |
||||
|
- **REQUIRED**: Create/verify ignore files based on actual project setup: |
||||
|
|
||||
|
**Detection & Creation Logic**: |
||||
|
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so): |
||||
|
|
||||
|
```sh |
||||
|
git rev-parse --git-dir 2>/dev/null |
||||
|
``` |
||||
|
|
||||
|
- Check if Dockerfile\* exists or Docker in plan.md → create/verify .dockerignore |
||||
|
- Check if .eslintrc\* exists → create/verify .eslintignore |
||||
|
- Check if eslint.config.\* exists → ensure the config's `ignores` entries cover required patterns |
||||
|
- Check if .prettierrc\* exists → create/verify .prettierignore |
||||
|
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing) |
||||
|
- Check if terraform files (\*.tf) exist → create/verify .terraformignore |
||||
|
- Check if .helmignore needed (helm charts present) → create/verify .helmignore |
||||
|
|
||||
|
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only |
||||
|
**If ignore file missing**: Create with full pattern set for detected technology |
||||
|
|
||||
|
**Common Patterns by Technology** (from plan.md tech stack): |
||||
|
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*` |
||||
|
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/` |
||||
|
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/` |
||||
|
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/` |
||||
|
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out` |
||||
|
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/` |
||||
|
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env` |
||||
|
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*` |
||||
|
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*` |
||||
|
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*` |
||||
|
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*` |
||||
|
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/` |
||||
|
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/` |
||||
|
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/` |
||||
|
|
||||
|
**Tool-Specific Patterns**: |
||||
|
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/` |
||||
|
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js` |
||||
|
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml` |
||||
|
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl` |
||||
|
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt` |
||||
|
|
||||
|
5. Parse tasks.md structure and extract: |
||||
|
- **Task phases**: Setup, Tests, Core, Integration, Polish |
||||
|
- **Task dependencies**: Sequential vs parallel execution rules |
||||
|
- **Task details**: ID, description, file paths, parallel markers [P] |
||||
|
- **Execution flow**: Order and dependency requirements |
||||
|
|
||||
|
6. Execute implementation following the task plan: |
||||
|
- **Phase-by-phase execution**: Complete each phase before moving to the next |
||||
|
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together |
||||
|
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks |
||||
|
- **File-based coordination**: Tasks affecting the same files must run sequentially |
||||
|
- **Validation checkpoints**: Verify each phase completion before proceeding |
||||
|
|
||||
|
7. Implementation execution rules: |
||||
|
- **Setup first**: Initialize project structure, dependencies, configuration |
||||
|
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios |
||||
|
- **Core development**: Implement models, services, CLI commands, endpoints |
||||
|
- **Integration work**: Database connections, middleware, logging, external services |
||||
|
- **Polish and validation**: Unit tests, performance optimization, documentation |
||||
|
|
||||
|
8. Progress tracking and error handling: |
||||
|
- Report progress after each completed task |
||||
|
- Halt execution if any non-parallel task fails |
||||
|
- For parallel tasks [P], continue with successful tasks, report failed ones |
||||
|
- Provide clear error messages with context for debugging |
||||
|
- Suggest next steps if implementation cannot proceed |
||||
|
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file. |
||||
|
|
||||
|
9. Completion validation: |
||||
|
- Verify all required tasks are completed |
||||
|
- Check that implemented features match the original specification |
||||
|
- Validate that tests pass and coverage meets requirements |
||||
|
- Confirm the implementation follows the technical plan |
||||
|
- Report final status with summary of completed work |
||||
|
|
||||
|
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list. |
||||
|
|
||||
|
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root. |
||||
|
- If it exists, read it and look for entries under the `hooks.after_implement` key |
||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally |
||||
|
- Filter to only hooks where `enabled: true` |
||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: |
||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable |
||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation |
||||
|
- For each executable hook, output the following based on its `optional` flag: |
||||
|
- **Optional hook** (`optional: true`): |
||||
|
|
||||
|
``` |
||||
|
## Extension Hooks |
||||
|
|
||||
|
**Optional Hook**: {extension} |
||||
|
Command: `/{command}` |
||||
|
Description: {description} |
||||
|
|
||||
|
Prompt: {prompt} |
||||
|
To execute: `/{command}` |
||||
|
``` |
||||
|
|
||||
|
- **Mandatory hook** (`optional: false`): |
||||
|
|
||||
|
``` |
||||
|
## Extension Hooks |
||||
|
|
||||
|
**Automatic Hook**: {extension} |
||||
|
Executing: `/{command}` |
||||
|
EXECUTE_COMMAND: {command} |
||||
|
``` |
||||
|
|
||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently |
||||
@ -0,0 +1,90 @@ |
|||||
|
--- |
||||
|
description: Execute the implementation planning workflow using the plan template to generate design artifacts. |
||||
|
handoffs: |
||||
|
- label: Create Tasks |
||||
|
agent: speckit.tasks |
||||
|
prompt: Break the plan into tasks |
||||
|
send: true |
||||
|
- label: Create Checklist |
||||
|
agent: speckit.checklist |
||||
|
prompt: Create a checklist for the following domain... |
||||
|
--- |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Outline |
||||
|
|
||||
|
1. **Setup**: Run `.specify/scripts/powershell/setup-plan.ps1 -Json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). |
||||
|
|
||||
|
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied). |
||||
|
|
||||
|
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to: |
||||
|
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION") |
||||
|
- Fill Constitution Check section from constitution |
||||
|
- Evaluate gates (ERROR if violations unjustified) |
||||
|
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION) |
||||
|
- Phase 1: Generate data-model.md, contracts/, quickstart.md |
||||
|
- Phase 1: Update agent context by running the agent script |
||||
|
- Re-evaluate Constitution Check post-design |
||||
|
|
||||
|
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts. |
||||
|
|
||||
|
## Phases |
||||
|
|
||||
|
### Phase 0: Outline & Research |
||||
|
|
||||
|
1. **Extract unknowns from Technical Context** above: |
||||
|
- For each NEEDS CLARIFICATION → research task |
||||
|
- For each dependency → best practices task |
||||
|
- For each integration → patterns task |
||||
|
|
||||
|
2. **Generate and dispatch research agents**: |
||||
|
|
||||
|
```text |
||||
|
For each unknown in Technical Context: |
||||
|
Task: "Research {unknown} for {feature context}" |
||||
|
For each technology choice: |
||||
|
Task: "Find best practices for {tech} in {domain}" |
||||
|
``` |
||||
|
|
||||
|
3. **Consolidate findings** in `research.md` using format: |
||||
|
- Decision: [what was chosen] |
||||
|
- Rationale: [why chosen] |
||||
|
- Alternatives considered: [what else evaluated] |
||||
|
|
||||
|
**Output**: research.md with all NEEDS CLARIFICATION resolved |
||||
|
|
||||
|
### Phase 1: Design & Contracts |
||||
|
|
||||
|
**Prerequisites:** `research.md` complete |
||||
|
|
||||
|
1. **Extract entities from feature spec** → `data-model.md`: |
||||
|
- Entity name, fields, relationships |
||||
|
- Validation rules from requirements |
||||
|
- State transitions if applicable |
||||
|
|
||||
|
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`: |
||||
|
- Identify what interfaces the project exposes to users or other systems |
||||
|
- Document the contract format appropriate for the project type |
||||
|
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications |
||||
|
- Skip if project is purely internal (build scripts, one-off tools, etc.) |
||||
|
|
||||
|
3. **Agent context update**: |
||||
|
- Run `.specify/scripts/powershell/update-agent-context.ps1 -AgentType copilot` |
||||
|
- These scripts detect which AI agent is in use |
||||
|
- Update the appropriate agent-specific context file |
||||
|
- Add only new technology from current plan |
||||
|
- Preserve manual additions between markers |
||||
|
|
||||
|
**Output**: data-model.md, /contracts/\*, quickstart.md, agent-specific file |
||||
|
|
||||
|
## Key rules |
||||
|
|
||||
|
- Use absolute paths |
||||
|
- ERROR on gate failures or unresolved clarifications |
||||
@ -0,0 +1,234 @@ |
|||||
|
--- |
||||
|
description: Create or update the feature specification from a natural language feature description. |
||||
|
handoffs: |
||||
|
- label: Build Technical Plan |
||||
|
agent: speckit.plan |
||||
|
prompt: Create a plan for the spec. I am building with... |
||||
|
- label: Clarify Spec Requirements |
||||
|
agent: speckit.clarify |
||||
|
prompt: Clarify specification requirements |
||||
|
send: true |
||||
|
--- |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Outline |
||||
|
|
||||
|
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command. |
||||
|
|
||||
|
Given that feature description, do this: |
||||
|
|
||||
|
1. **Generate a concise short name** (2-4 words) for the branch: |
||||
|
- Analyze the feature description and extract the most meaningful keywords |
||||
|
- Create a 2-4 word short name that captures the essence of the feature |
||||
|
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") |
||||
|
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) |
||||
|
- Keep it concise but descriptive enough to understand the feature at a glance |
||||
|
- Examples: |
||||
|
- "I want to add user authentication" → "user-auth" |
||||
|
- "Implement OAuth2 integration for the API" → "oauth2-api-integration" |
||||
|
- "Create a dashboard for analytics" → "analytics-dashboard" |
||||
|
- "Fix payment processing timeout bug" → "fix-payment-timeout" |
||||
|
|
||||
|
2. **Create the feature branch** by running the script with `--short-name` (and `--json`), and do NOT pass `--number` (the script auto-detects the next globally available number across all branches and spec directories): |
||||
|
- Bash example: `.specify/scripts/powershell/create-new-feature.ps1 "$ARGUMENTS" --json --short-name "user-auth" "Add user authentication"` |
||||
|
- PowerShell example: `.specify/scripts/powershell/create-new-feature.ps1 "$ARGUMENTS" -Json -ShortName "user-auth" "Add user authentication"` |
||||
|
|
||||
|
**IMPORTANT**: |
||||
|
- Do NOT pass `--number` — the script determines the correct next number automatically |
||||
|
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably |
||||
|
- You must only ever run this script once per feature |
||||
|
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for |
||||
|
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths |
||||
|
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot") |
||||
|
|
||||
|
3. Load `.specify/templates/spec-template.md` to understand required sections. |
||||
|
|
||||
|
4. Follow this execution flow: |
||||
|
1. Parse user description from Input |
||||
|
If empty: ERROR "No feature description provided" |
||||
|
2. Extract key concepts from description |
||||
|
Identify: actors, actions, data, constraints |
||||
|
3. For unclear aspects: |
||||
|
- Make informed guesses based on context and industry standards |
||||
|
- Only mark with [NEEDS CLARIFICATION: specific question] if: |
||||
|
- The choice significantly impacts feature scope or user experience |
||||
|
- Multiple reasonable interpretations exist with different implications |
||||
|
- No reasonable default exists |
||||
|
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total** |
||||
|
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details |
||||
|
4. Fill User Scenarios & Testing section |
||||
|
If no clear user flow: ERROR "Cannot determine user scenarios" |
||||
|
5. Generate Functional Requirements |
||||
|
Each requirement must be testable |
||||
|
Use reasonable defaults for unspecified details (document assumptions in Assumptions section) |
||||
|
6. Define Success Criteria |
||||
|
Create measurable, technology-agnostic outcomes |
||||
|
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion) |
||||
|
Each criterion must be verifiable without implementation details |
||||
|
7. Identify Key Entities (if data involved) |
||||
|
8. Return: SUCCESS (spec ready for planning) |
||||
|
|
||||
|
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. |
||||
|
|
||||
|
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: |
||||
|
|
||||
|
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items: |
||||
|
|
||||
|
```markdown |
||||
|
# Specification Quality Checklist: [FEATURE NAME] |
||||
|
|
||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning |
||||
|
**Created**: [DATE] |
||||
|
**Feature**: [Link to spec.md] |
||||
|
|
||||
|
## Content Quality |
||||
|
|
||||
|
- [ ] No implementation details (languages, frameworks, APIs) |
||||
|
- [ ] Focused on user value and business needs |
||||
|
- [ ] Written for non-technical stakeholders |
||||
|
- [ ] All mandatory sections completed |
||||
|
|
||||
|
## Requirement Completeness |
||||
|
|
||||
|
- [ ] No [NEEDS CLARIFICATION] markers remain |
||||
|
- [ ] Requirements are testable and unambiguous |
||||
|
- [ ] Success criteria are measurable |
||||
|
- [ ] Success criteria are technology-agnostic (no implementation details) |
||||
|
- [ ] All acceptance scenarios are defined |
||||
|
- [ ] Edge cases are identified |
||||
|
- [ ] Scope is clearly bounded |
||||
|
- [ ] Dependencies and assumptions identified |
||||
|
|
||||
|
## Feature Readiness |
||||
|
|
||||
|
- [ ] All functional requirements have clear acceptance criteria |
||||
|
- [ ] User scenarios cover primary flows |
||||
|
- [ ] Feature meets measurable outcomes defined in Success Criteria |
||||
|
- [ ] No implementation details leak into specification |
||||
|
|
||||
|
## Notes |
||||
|
|
||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` |
||||
|
``` |
||||
|
|
||||
|
b. **Run Validation Check**: Review the spec against each checklist item: |
||||
|
- For each item, determine if it passes or fails |
||||
|
- Document specific issues found (quote relevant spec sections) |
||||
|
|
||||
|
c. **Handle Validation Results**: |
||||
|
- **If all items pass**: Mark checklist complete and proceed to step 7 |
||||
|
|
||||
|
- **If items fail (excluding [NEEDS CLARIFICATION])**: |
||||
|
1. List the failing items and specific issues |
||||
|
2. Update the spec to address each issue |
||||
|
3. Re-run validation until all items pass (max 3 iterations) |
||||
|
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user |
||||
|
|
||||
|
- **If [NEEDS CLARIFICATION] markers remain**: |
||||
|
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec |
||||
|
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest |
||||
|
3. For each clarification needed (max 3), present options to user in this format: |
||||
|
|
||||
|
```markdown |
||||
|
## Question [N]: [Topic] |
||||
|
|
||||
|
**Context**: [Quote relevant spec section] |
||||
|
|
||||
|
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker] |
||||
|
|
||||
|
**Suggested Answers**: |
||||
|
|
||||
|
| Option | Answer | Implications | |
||||
|
| ------ | ------------------------- | ------------------------------------- | |
||||
|
| A | [First suggested answer] | [What this means for the feature] | |
||||
|
| B | [Second suggested answer] | [What this means for the feature] | |
||||
|
| C | [Third suggested answer] | [What this means for the feature] | |
||||
|
| Custom | Provide your own answer | [Explain how to provide custom input] | |
||||
|
|
||||
|
**Your choice**: _[Wait for user response]_ |
||||
|
``` |
||||
|
|
||||
|
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted: |
||||
|
- Use consistent spacing with pipes aligned |
||||
|
- Each cell should have spaces around content: `| Content |` not `|Content|` |
||||
|
- Header separator must have at least 3 dashes: `|--------|` |
||||
|
- Test that the table renders correctly in markdown preview |
||||
|
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total) |
||||
|
6. Present all questions together before waiting for responses |
||||
|
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B") |
||||
|
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer |
||||
|
9. Re-run validation after all clarifications are resolved |
||||
|
|
||||
|
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status |
||||
|
|
||||
|
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). |
||||
|
|
||||
|
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. |
||||
|
|
||||
|
## Quick Guidelines |
||||
|
|
||||
|
- Focus on **WHAT** users need and **WHY**. |
||||
|
- Avoid HOW to implement (no tech stack, APIs, code structure). |
||||
|
- Written for business stakeholders, not developers. |
||||
|
- DO NOT create any checklists that are embedded in the spec. That will be a separate command. |
||||
|
|
||||
|
### Section Requirements |
||||
|
|
||||
|
- **Mandatory sections**: Must be completed for every feature |
||||
|
- **Optional sections**: Include only when relevant to the feature |
||||
|
- When a section doesn't apply, remove it entirely (don't leave as "N/A") |
||||
|
|
||||
|
### For AI Generation |
||||
|
|
||||
|
When creating this spec from a user prompt: |
||||
|
|
||||
|
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps |
||||
|
2. **Document assumptions**: Record reasonable defaults in the Assumptions section |
||||
|
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that: |
||||
|
- Significantly impact feature scope or user experience |
||||
|
- Have multiple reasonable interpretations with different implications |
||||
|
- Lack any reasonable default |
||||
|
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details |
||||
|
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item |
||||
|
6. **Common areas needing clarification** (only if no reasonable default exists): |
||||
|
- Feature scope and boundaries (include/exclude specific use cases) |
||||
|
- User types and permissions (if multiple conflicting interpretations possible) |
||||
|
- Security/compliance requirements (when legally/financially significant) |
||||
|
|
||||
|
**Examples of reasonable defaults** (don't ask about these): |
||||
|
|
||||
|
- Data retention: Industry-standard practices for the domain |
||||
|
- Performance targets: Standard web/mobile app expectations unless specified |
||||
|
- Error handling: User-friendly messages with appropriate fallbacks |
||||
|
- Authentication method: Standard session-based or OAuth2 for web apps |
||||
|
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.) |
||||
|
|
||||
|
### Success Criteria Guidelines |
||||
|
|
||||
|
Success criteria must be: |
||||
|
|
||||
|
1. **Measurable**: Include specific metrics (time, percentage, count, rate) |
||||
|
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools |
||||
|
3. **User-focused**: Describe outcomes from user/business perspective, not system internals |
||||
|
4. **Verifiable**: Can be tested/validated without knowing implementation details |
||||
|
|
||||
|
**Good examples**: |
||||
|
|
||||
|
- "Users can complete checkout in under 3 minutes" |
||||
|
- "System supports 10,000 concurrent users" |
||||
|
- "95% of searches return results in under 1 second" |
||||
|
- "Task completion rate improves by 40%" |
||||
|
|
||||
|
**Bad examples** (implementation-focused): |
||||
|
|
||||
|
- "API response time is under 200ms" (too technical, use "Users see results instantly") |
||||
|
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric) |
||||
|
- "React components render efficiently" (framework-specific) |
||||
|
- "Redis cache hit rate above 80%" (technology-specific) |
||||
@ -0,0 +1,209 @@ |
|||||
|
--- |
||||
|
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. |
||||
|
handoffs: |
||||
|
- label: Analyze For Consistency |
||||
|
agent: speckit.analyze |
||||
|
prompt: Run a project analysis for consistency |
||||
|
send: true |
||||
|
- label: Implement Project |
||||
|
agent: speckit.implement |
||||
|
prompt: Start the implementation in phases |
||||
|
send: true |
||||
|
--- |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Pre-Execution Checks |
||||
|
|
||||
|
**Check for extension hooks (before tasks generation)**: |
||||
|
|
||||
|
- Check if `.specify/extensions.yml` exists in the project root. |
||||
|
- If it exists, read it and look for entries under the `hooks.before_tasks` key |
||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally |
||||
|
- Filter to only hooks where `enabled: true` |
||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: |
||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable |
||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation |
||||
|
- For each executable hook, output the following based on its `optional` flag: |
||||
|
- **Optional hook** (`optional: true`): |
||||
|
|
||||
|
``` |
||||
|
## Extension Hooks |
||||
|
|
||||
|
**Optional Pre-Hook**: {extension} |
||||
|
Command: `/{command}` |
||||
|
Description: {description} |
||||
|
|
||||
|
Prompt: {prompt} |
||||
|
To execute: `/{command}` |
||||
|
``` |
||||
|
|
||||
|
- **Mandatory hook** (`optional: false`): |
||||
|
|
||||
|
``` |
||||
|
## Extension Hooks |
||||
|
|
||||
|
**Automatic Pre-Hook**: {extension} |
||||
|
Executing: `/{command}` |
||||
|
EXECUTE_COMMAND: {command} |
||||
|
|
||||
|
Wait for the result of the hook command before proceeding to the Outline. |
||||
|
``` |
||||
|
|
||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently |
||||
|
|
||||
|
## Outline |
||||
|
|
||||
|
1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). |
||||
|
|
||||
|
2. **Load design documents**: Read from FEATURE_DIR: |
||||
|
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) |
||||
|
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios) |
||||
|
- Note: Not all projects have all documents. Generate tasks based on what's available. |
||||
|
|
||||
|
3. **Execute task generation workflow**: |
||||
|
- Load plan.md and extract tech stack, libraries, project structure |
||||
|
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.) |
||||
|
- If data-model.md exists: Extract entities and map to user stories |
||||
|
- If contracts/ exists: Map interface contracts to user stories |
||||
|
- If research.md exists: Extract decisions for setup tasks |
||||
|
- Generate tasks organized by user story (see Task Generation Rules below) |
||||
|
- Generate dependency graph showing user story completion order |
||||
|
- Create parallel execution examples per user story |
||||
|
- Validate task completeness (each user story has all needed tasks, independently testable) |
||||
|
|
||||
|
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with: |
||||
|
- Correct feature name from plan.md |
||||
|
- Phase 1: Setup tasks (project initialization) |
||||
|
- Phase 2: Foundational tasks (blocking prerequisites for all user stories) |
||||
|
- Phase 3+: One phase per user story (in priority order from spec.md) |
||||
|
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks |
||||
|
- Final Phase: Polish & cross-cutting concerns |
||||
|
- All tasks must follow the strict checklist format (see Task Generation Rules below) |
||||
|
- Clear file paths for each task |
||||
|
- Dependencies section showing story completion order |
||||
|
- Parallel execution examples per story |
||||
|
- Implementation strategy section (MVP first, incremental delivery) |
||||
|
|
||||
|
5. **Report**: Output path to generated tasks.md and summary: |
||||
|
- Total task count |
||||
|
- Task count per user story |
||||
|
- Parallel opportunities identified |
||||
|
- Independent test criteria for each story |
||||
|
- Suggested MVP scope (typically just User Story 1) |
||||
|
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths) |
||||
|
|
||||
|
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root. |
||||
|
- If it exists, read it and look for entries under the `hooks.after_tasks` key |
||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally |
||||
|
- Filter to only hooks where `enabled: true` |
||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: |
||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable |
||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation |
||||
|
- For each executable hook, output the following based on its `optional` flag: |
||||
|
- **Optional hook** (`optional: true`): |
||||
|
|
||||
|
``` |
||||
|
## Extension Hooks |
||||
|
|
||||
|
**Optional Hook**: {extension} |
||||
|
Command: `/{command}` |
||||
|
Description: {description} |
||||
|
|
||||
|
Prompt: {prompt} |
||||
|
To execute: `/{command}` |
||||
|
``` |
||||
|
|
||||
|
- **Mandatory hook** (`optional: false`): |
||||
|
|
||||
|
``` |
||||
|
## Extension Hooks |
||||
|
|
||||
|
**Automatic Hook**: {extension} |
||||
|
Executing: `/{command}` |
||||
|
EXECUTE_COMMAND: {command} |
||||
|
``` |
||||
|
|
||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently |
||||
|
|
||||
|
Context for task generation: $ARGUMENTS |
||||
|
|
||||
|
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context. |
||||
|
|
||||
|
## Task Generation Rules |
||||
|
|
||||
|
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing. |
||||
|
|
||||
|
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach. |
||||
|
|
||||
|
### Checklist Format (REQUIRED) |
||||
|
|
||||
|
Every task MUST strictly follow this format: |
||||
|
|
||||
|
```text |
||||
|
- [ ] [TaskID] [P?] [Story?] Description with file path |
||||
|
``` |
||||
|
|
||||
|
**Format Components**: |
||||
|
|
||||
|
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox) |
||||
|
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order |
||||
|
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks) |
||||
|
4. **[Story] label**: REQUIRED for user story phase tasks only |
||||
|
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md) |
||||
|
- Setup phase: NO story label |
||||
|
- Foundational phase: NO story label |
||||
|
- User Story phases: MUST have story label |
||||
|
- Polish phase: NO story label |
||||
|
5. **Description**: Clear action with exact file path |
||||
|
|
||||
|
**Examples**: |
||||
|
|
||||
|
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan` |
||||
|
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py` |
||||
|
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py` |
||||
|
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py` |
||||
|
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label) |
||||
|
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox) |
||||
|
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID) |
||||
|
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path) |
||||
|
|
||||
|
### Task Organization |
||||
|
|
||||
|
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION: |
||||
|
- Each user story (P1, P2, P3...) gets its own phase |
||||
|
- Map all related components to their story: |
||||
|
- Models needed for that story |
||||
|
- Services needed for that story |
||||
|
- Interfaces/UI needed for that story |
||||
|
- If tests requested: Tests specific to that story |
||||
|
- Mark story dependencies (most stories should be independent) |
||||
|
|
||||
|
2. **From Contracts**: |
||||
|
- Map each interface contract → to the user story it serves |
||||
|
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase |
||||
|
|
||||
|
3. **From Data Model**: |
||||
|
- Map each entity to the user story(ies) that need it |
||||
|
- If entity serves multiple stories: Put in earliest story or Setup phase |
||||
|
- Relationships → service layer tasks in appropriate story phase |
||||
|
|
||||
|
4. **From Setup/Infrastructure**: |
||||
|
- Shared infrastructure → Setup phase (Phase 1) |
||||
|
- Foundational/blocking tasks → Foundational phase (Phase 2) |
||||
|
- Story-specific setup → within that story's phase |
||||
|
|
||||
|
### Phase Structure |
||||
|
|
||||
|
- **Phase 1**: Setup (project initialization) |
||||
|
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories) |
||||
|
- **Phase 3+**: User Stories in priority order (P1, P2, P3...) |
||||
|
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration |
||||
|
- Each phase should be a complete, independently testable increment |
||||
|
- **Final Phase**: Polish & Cross-Cutting Concerns |
||||
@ -0,0 +1,30 @@ |
|||||
|
--- |
||||
|
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts. |
||||
|
tools: ['github/github-mcp-server/issue_write'] |
||||
|
--- |
||||
|
|
||||
|
## User Input |
||||
|
|
||||
|
```text |
||||
|
$ARGUMENTS |
||||
|
``` |
||||
|
|
||||
|
You **MUST** consider the user input before proceeding (if not empty). |
||||
|
|
||||
|
## Outline |
||||
|
|
||||
|
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). |
||||
|
1. From the executed script, extract the path to **tasks**. |
||||
|
1. Get the Git remote by running: |
||||
|
|
||||
|
```bash |
||||
|
git config --get remote.origin.url |
||||
|
``` |
||||
|
|
||||
|
> [!CAUTION] |
||||
|
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL |
||||
|
|
||||
|
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. |
||||
|
|
||||
|
> [!CAUTION] |
||||
|
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.analyze |
||||
|
--- |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.checklist |
||||
|
--- |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.clarify |
||||
|
--- |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.constitution |
||||
|
--- |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.implement |
||||
|
--- |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.plan |
||||
|
--- |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.specify |
||||
|
--- |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.tasks |
||||
|
--- |
||||
@ -0,0 +1,3 @@ |
|||||
|
--- |
||||
|
agent: speckit.taskstoissues |
||||
|
--- |
||||
@ -0,0 +1,148 @@ |
|||||
|
#!/usr/bin/env pwsh |
||||
|
|
||||
|
# Consolidated prerequisite checking script (PowerShell) |
||||
|
# |
||||
|
# This script provides unified prerequisite checking for Spec-Driven Development workflow. |
||||
|
# It replaces the functionality previously spread across multiple scripts. |
||||
|
# |
||||
|
# Usage: ./check-prerequisites.ps1 [OPTIONS] |
||||
|
# |
||||
|
# OPTIONS: |
||||
|
# -Json Output in JSON format |
||||
|
# -RequireTasks Require tasks.md to exist (for implementation phase) |
||||
|
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list |
||||
|
# -PathsOnly Only output path variables (no validation) |
||||
|
# -Help, -h Show help message |
||||
|
|
||||
|
[CmdletBinding()] |
||||
|
param( |
||||
|
[switch]$Json, |
||||
|
[switch]$RequireTasks, |
||||
|
[switch]$IncludeTasks, |
||||
|
[switch]$PathsOnly, |
||||
|
[switch]$Help |
||||
|
) |
||||
|
|
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
|
||||
|
# Show help if requested |
||||
|
if ($Help) { |
||||
|
Write-Output @" |
||||
|
Usage: check-prerequisites.ps1 [OPTIONS] |
||||
|
|
||||
|
Consolidated prerequisite checking for Spec-Driven Development workflow. |
||||
|
|
||||
|
OPTIONS: |
||||
|
-Json Output in JSON format |
||||
|
-RequireTasks Require tasks.md to exist (for implementation phase) |
||||
|
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list |
||||
|
-PathsOnly Only output path variables (no prerequisite validation) |
||||
|
-Help, -h Show this help message |
||||
|
|
||||
|
EXAMPLES: |
||||
|
# Check task prerequisites (plan.md required) |
||||
|
.\check-prerequisites.ps1 -Json |
||||
|
|
||||
|
# Check implementation prerequisites (plan.md + tasks.md required) |
||||
|
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks |
||||
|
|
||||
|
# Get feature paths only (no validation) |
||||
|
.\check-prerequisites.ps1 -PathsOnly |
||||
|
|
||||
|
"@ |
||||
|
exit 0 |
||||
|
} |
||||
|
|
||||
|
# Source common functions |
||||
|
. "$PSScriptRoot/common.ps1" |
||||
|
|
||||
|
# Get feature paths and validate branch |
||||
|
$paths = Get-FeaturePathsEnv |
||||
|
|
||||
|
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
# If paths-only mode, output paths and exit (support combined -Json -PathsOnly) |
||||
|
if ($PathsOnly) { |
||||
|
if ($Json) { |
||||
|
[PSCustomObject]@{ |
||||
|
REPO_ROOT = $paths.REPO_ROOT |
||||
|
BRANCH = $paths.CURRENT_BRANCH |
||||
|
FEATURE_DIR = $paths.FEATURE_DIR |
||||
|
FEATURE_SPEC = $paths.FEATURE_SPEC |
||||
|
IMPL_PLAN = $paths.IMPL_PLAN |
||||
|
TASKS = $paths.TASKS |
||||
|
} | ConvertTo-Json -Compress |
||||
|
} else { |
||||
|
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)" |
||||
|
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" |
||||
|
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" |
||||
|
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" |
||||
|
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" |
||||
|
Write-Output "TASKS: $($paths.TASKS)" |
||||
|
} |
||||
|
exit 0 |
||||
|
} |
||||
|
|
||||
|
# Validate required directories and files |
||||
|
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { |
||||
|
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" |
||||
|
Write-Output "Run /speckit.specify first to create the feature structure." |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { |
||||
|
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)" |
||||
|
Write-Output "Run /speckit.plan first to create the implementation plan." |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
# Check for tasks.md if required |
||||
|
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) { |
||||
|
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)" |
||||
|
Write-Output "Run /speckit.tasks first to create the task list." |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
# Build list of available documents |
||||
|
$docs = @() |
||||
|
|
||||
|
# Always check these optional docs |
||||
|
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' } |
||||
|
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' } |
||||
|
|
||||
|
# Check contracts directory (only if it exists and has files) |
||||
|
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { |
||||
|
$docs += 'contracts/' |
||||
|
} |
||||
|
|
||||
|
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } |
||||
|
|
||||
|
# Include tasks.md if requested and it exists |
||||
|
if ($IncludeTasks -and (Test-Path $paths.TASKS)) { |
||||
|
$docs += 'tasks.md' |
||||
|
} |
||||
|
|
||||
|
# Output results |
||||
|
if ($Json) { |
||||
|
# JSON output |
||||
|
[PSCustomObject]@{ |
||||
|
FEATURE_DIR = $paths.FEATURE_DIR |
||||
|
AVAILABLE_DOCS = $docs |
||||
|
} | ConvertTo-Json -Compress |
||||
|
} else { |
||||
|
# Text output |
||||
|
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)" |
||||
|
Write-Output "AVAILABLE_DOCS:" |
||||
|
|
||||
|
# Show status of each potential document |
||||
|
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null |
||||
|
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null |
||||
|
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null |
||||
|
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null |
||||
|
|
||||
|
if ($IncludeTasks) { |
||||
|
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,204 @@ |
|||||
|
#!/usr/bin/env pwsh |
||||
|
# Common PowerShell functions analogous to common.sh |
||||
|
|
||||
|
function Get-RepoRoot { |
||||
|
try { |
||||
|
$result = git rev-parse --show-toplevel 2>$null |
||||
|
if ($LASTEXITCODE -eq 0) { |
||||
|
return $result |
||||
|
} |
||||
|
} catch { |
||||
|
# Git command failed |
||||
|
} |
||||
|
|
||||
|
# Fall back to script location for non-git repos |
||||
|
return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path |
||||
|
} |
||||
|
|
||||
|
function Get-CurrentBranch { |
||||
|
# First check if SPECIFY_FEATURE environment variable is set |
||||
|
if ($env:SPECIFY_FEATURE) { |
||||
|
return $env:SPECIFY_FEATURE |
||||
|
} |
||||
|
|
||||
|
# Then check git if available |
||||
|
try { |
||||
|
$result = git rev-parse --abbrev-ref HEAD 2>$null |
||||
|
if ($LASTEXITCODE -eq 0) { |
||||
|
return $result |
||||
|
} |
||||
|
} catch { |
||||
|
# Git command failed |
||||
|
} |
||||
|
|
||||
|
# For non-git repos, try to find the latest feature directory |
||||
|
$repoRoot = Get-RepoRoot |
||||
|
$specsDir = Join-Path $repoRoot "specs" |
||||
|
|
||||
|
if (Test-Path $specsDir) { |
||||
|
$latestFeature = "" |
||||
|
$highest = 0 |
||||
|
|
||||
|
Get-ChildItem -Path $specsDir -Directory | ForEach-Object { |
||||
|
if ($_.Name -match '^(\d{3})-') { |
||||
|
$num = [int]$matches[1] |
||||
|
if ($num -gt $highest) { |
||||
|
$highest = $num |
||||
|
$latestFeature = $_.Name |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if ($latestFeature) { |
||||
|
return $latestFeature |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# Final fallback |
||||
|
return "main" |
||||
|
} |
||||
|
|
||||
|
function Test-HasGit { |
||||
|
try { |
||||
|
git rev-parse --show-toplevel 2>$null | Out-Null |
||||
|
return ($LASTEXITCODE -eq 0) |
||||
|
} catch { |
||||
|
return $false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Test-FeatureBranch { |
||||
|
param( |
||||
|
[string]$Branch, |
||||
|
[bool]$HasGit = $true |
||||
|
) |
||||
|
|
||||
|
# For non-git repos, we can't enforce branch naming but still provide output |
||||
|
if (-not $HasGit) { |
||||
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" |
||||
|
return $true |
||||
|
} |
||||
|
|
||||
|
if ($Branch -notmatch '^[0-9]{3}-') { |
||||
|
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" |
||||
|
Write-Output "Feature branches should be named like: 001-feature-name" |
||||
|
return $false |
||||
|
} |
||||
|
return $true |
||||
|
} |
||||
|
|
||||
|
function Get-FeatureDir { |
||||
|
param([string]$RepoRoot, [string]$Branch) |
||||
|
Join-Path $RepoRoot "specs/$Branch" |
||||
|
} |
||||
|
|
||||
|
function Get-FeaturePathsEnv { |
||||
|
$repoRoot = Get-RepoRoot |
||||
|
$currentBranch = Get-CurrentBranch |
||||
|
$hasGit = Test-HasGit |
||||
|
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch |
||||
|
|
||||
|
[PSCustomObject]@{ |
||||
|
REPO_ROOT = $repoRoot |
||||
|
CURRENT_BRANCH = $currentBranch |
||||
|
HAS_GIT = $hasGit |
||||
|
FEATURE_DIR = $featureDir |
||||
|
FEATURE_SPEC = Join-Path $featureDir 'spec.md' |
||||
|
IMPL_PLAN = Join-Path $featureDir 'plan.md' |
||||
|
TASKS = Join-Path $featureDir 'tasks.md' |
||||
|
RESEARCH = Join-Path $featureDir 'research.md' |
||||
|
DATA_MODEL = Join-Path $featureDir 'data-model.md' |
||||
|
QUICKSTART = Join-Path $featureDir 'quickstart.md' |
||||
|
CONTRACTS_DIR = Join-Path $featureDir 'contracts' |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Test-FileExists { |
||||
|
param([string]$Path, [string]$Description) |
||||
|
if (Test-Path -Path $Path -PathType Leaf) { |
||||
|
Write-Output " ✓ $Description" |
||||
|
return $true |
||||
|
} else { |
||||
|
Write-Output " ✗ $Description" |
||||
|
return $false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Test-DirHasFiles { |
||||
|
param([string]$Path, [string]$Description) |
||||
|
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) { |
||||
|
Write-Output " ✓ $Description" |
||||
|
return $true |
||||
|
} else { |
||||
|
Write-Output " ✗ $Description" |
||||
|
return $false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# Resolve a template name to a file path using the priority stack: |
||||
|
# 1. .specify/templates/overrides/ |
||||
|
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry) |
||||
|
# 3. .specify/extensions/<ext-id>/templates/ |
||||
|
# 4. .specify/templates/ (core) |
||||
|
function Resolve-Template { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)][string]$TemplateName, |
||||
|
[Parameter(Mandatory=$true)][string]$RepoRoot |
||||
|
) |
||||
|
|
||||
|
$base = Join-Path $RepoRoot '.specify/templates' |
||||
|
|
||||
|
# Priority 1: Project overrides |
||||
|
$override = Join-Path $base "overrides/$TemplateName.md" |
||||
|
if (Test-Path $override) { return $override } |
||||
|
|
||||
|
# Priority 2: Installed presets (sorted by priority from .registry) |
||||
|
$presetsDir = Join-Path $RepoRoot '.specify/presets' |
||||
|
if (Test-Path $presetsDir) { |
||||
|
$registryFile = Join-Path $presetsDir '.registry' |
||||
|
$sortedPresets = @() |
||||
|
if (Test-Path $registryFile) { |
||||
|
try { |
||||
|
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json |
||||
|
$presets = $registryData.presets |
||||
|
if ($presets) { |
||||
|
$sortedPresets = $presets.PSObject.Properties | |
||||
|
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | |
||||
|
ForEach-Object { $_.Name } |
||||
|
} |
||||
|
} catch { |
||||
|
# Fallback: alphabetical directory order |
||||
|
$sortedPresets = @() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if ($sortedPresets.Count -gt 0) { |
||||
|
foreach ($presetId in $sortedPresets) { |
||||
|
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" |
||||
|
if (Test-Path $candidate) { return $candidate } |
||||
|
} |
||||
|
} else { |
||||
|
# Fallback: alphabetical directory order |
||||
|
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { |
||||
|
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md" |
||||
|
if (Test-Path $candidate) { return $candidate } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# Priority 3: Extension-provided templates |
||||
|
$extDir = Join-Path $RepoRoot '.specify/extensions' |
||||
|
if (Test-Path $extDir) { |
||||
|
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { |
||||
|
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md" |
||||
|
if (Test-Path $candidate) { return $candidate } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# Priority 4: Core templates |
||||
|
$core = Join-Path $base "$TemplateName.md" |
||||
|
if (Test-Path $core) { return $core } |
||||
|
|
||||
|
return $null |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,308 @@ |
|||||
|
#!/usr/bin/env pwsh |
||||
|
# Create a new feature |
||||
|
[CmdletBinding()] |
||||
|
param( |
||||
|
[switch]$Json, |
||||
|
[string]$ShortName, |
||||
|
[int]$Number = 0, |
||||
|
[switch]$Help, |
||||
|
[Parameter(ValueFromRemainingArguments = $true)] |
||||
|
[string[]]$FeatureDescription |
||||
|
) |
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
|
||||
|
# Show help if requested |
||||
|
if ($Help) { |
||||
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>" |
||||
|
Write-Host "" |
||||
|
Write-Host "Options:" |
||||
|
Write-Host " -Json Output in JSON format" |
||||
|
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch" |
||||
|
Write-Host " -Number N Specify branch number manually (overrides auto-detection)" |
||||
|
Write-Host " -Help Show this help message" |
||||
|
Write-Host "" |
||||
|
Write-Host "Examples:" |
||||
|
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" |
||||
|
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" |
||||
|
exit 0 |
||||
|
} |
||||
|
|
||||
|
# Check if feature description provided |
||||
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { |
||||
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] <feature description>" |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
$featureDesc = ($FeatureDescription -join ' ').Trim() |
||||
|
|
||||
|
# Validate description is not empty after trimming (e.g., user passed only whitespace) |
||||
|
if ([string]::IsNullOrWhiteSpace($featureDesc)) { |
||||
|
Write-Error "Error: Feature description cannot be empty or contain only whitespace" |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
# Resolve repository root. Prefer git information when available, but fall back |
||||
|
# to searching for repository markers so the workflow still functions in repositories that |
||||
|
# were initialized with --no-git. |
||||
|
function Find-RepositoryRoot { |
||||
|
param( |
||||
|
[string]$StartDir, |
||||
|
[string[]]$Markers = @('.git', '.specify') |
||||
|
) |
||||
|
$current = Resolve-Path $StartDir |
||||
|
while ($true) { |
||||
|
foreach ($marker in $Markers) { |
||||
|
if (Test-Path (Join-Path $current $marker)) { |
||||
|
return $current |
||||
|
} |
||||
|
} |
||||
|
$parent = Split-Path $current -Parent |
||||
|
if ($parent -eq $current) { |
||||
|
# Reached filesystem root without finding markers |
||||
|
return $null |
||||
|
} |
||||
|
$current = $parent |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Get-HighestNumberFromSpecs { |
||||
|
param([string]$SpecsDir) |
||||
|
|
||||
|
$highest = 0 |
||||
|
if (Test-Path $SpecsDir) { |
||||
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { |
||||
|
if ($_.Name -match '^(\d+)') { |
||||
|
$num = [int]$matches[1] |
||||
|
if ($num -gt $highest) { $highest = $num } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return $highest |
||||
|
} |
||||
|
|
||||
|
function Get-HighestNumberFromBranches { |
||||
|
param() |
||||
|
|
||||
|
$highest = 0 |
||||
|
try { |
||||
|
$branches = git branch -a 2>$null |
||||
|
if ($LASTEXITCODE -eq 0) { |
||||
|
foreach ($branch in $branches) { |
||||
|
# Clean branch name: remove leading markers and remote prefixes |
||||
|
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' |
||||
|
|
||||
|
# Extract feature number if branch matches pattern ###-* |
||||
|
if ($cleanBranch -match '^(\d+)-') { |
||||
|
$num = [int]$matches[1] |
||||
|
if ($num -gt $highest) { $highest = $num } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} catch { |
||||
|
# If git command fails, return 0 |
||||
|
Write-Verbose "Could not check Git branches: $_" |
||||
|
} |
||||
|
return $highest |
||||
|
} |
||||
|
|
||||
|
function Get-NextBranchNumber { |
||||
|
param( |
||||
|
[string]$SpecsDir |
||||
|
) |
||||
|
|
||||
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes) |
||||
|
try { |
||||
|
git fetch --all --prune 2>$null | Out-Null |
||||
|
} catch { |
||||
|
# Ignore fetch errors |
||||
|
} |
||||
|
|
||||
|
# Get highest number from ALL branches (not just matching short name) |
||||
|
$highestBranch = Get-HighestNumberFromBranches |
||||
|
|
||||
|
# Get highest number from ALL specs (not just matching short name) |
||||
|
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir |
||||
|
|
||||
|
# Take the maximum of both |
||||
|
$maxNum = [Math]::Max($highestBranch, $highestSpec) |
||||
|
|
||||
|
# Return next number |
||||
|
return $maxNum + 1 |
||||
|
} |
||||
|
|
||||
|
function ConvertTo-CleanBranchName { |
||||
|
param([string]$Name) |
||||
|
|
||||
|
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' |
||||
|
} |
||||
|
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) |
||||
|
if (-not $fallbackRoot) { |
||||
|
Write-Error "Error: Could not determine repository root. Please run this script from within the repository." |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
# Load common functions (includes Resolve-Template) |
||||
|
. "$PSScriptRoot/common.ps1" |
||||
|
|
||||
|
try { |
||||
|
$repoRoot = git rev-parse --show-toplevel 2>$null |
||||
|
if ($LASTEXITCODE -eq 0) { |
||||
|
$hasGit = $true |
||||
|
} else { |
||||
|
throw "Git not available" |
||||
|
} |
||||
|
} catch { |
||||
|
$repoRoot = $fallbackRoot |
||||
|
$hasGit = $false |
||||
|
} |
||||
|
|
||||
|
Set-Location $repoRoot |
||||
|
|
||||
|
$specsDir = Join-Path $repoRoot 'specs' |
||||
|
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null |
||||
|
|
||||
|
# Function to generate branch name with stop word filtering and length filtering |
||||
|
function Get-BranchName { |
||||
|
param([string]$Description) |
||||
|
|
||||
|
# Common stop words to filter out |
||||
|
$stopWords = @( |
||||
|
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', |
||||
|
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', |
||||
|
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', |
||||
|
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', |
||||
|
'want', 'need', 'add', 'get', 'set' |
||||
|
) |
||||
|
|
||||
|
# Convert to lowercase and extract words (alphanumeric only) |
||||
|
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' |
||||
|
$words = $cleanName -split '\s+' | Where-Object { $_ } |
||||
|
|
||||
|
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) |
||||
|
$meaningfulWords = @() |
||||
|
foreach ($word in $words) { |
||||
|
# Skip stop words |
||||
|
if ($stopWords -contains $word) { continue } |
||||
|
|
||||
|
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) |
||||
|
if ($word.Length -ge 3) { |
||||
|
$meaningfulWords += $word |
||||
|
} elseif ($Description -match "\b$($word.ToUpper())\b") { |
||||
|
# Keep short words if they appear as uppercase in original (likely acronyms) |
||||
|
$meaningfulWords += $word |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# If we have meaningful words, use first 3-4 of them |
||||
|
if ($meaningfulWords.Count -gt 0) { |
||||
|
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } |
||||
|
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' |
||||
|
return $result |
||||
|
} else { |
||||
|
# Fallback to original logic if no meaningful words found |
||||
|
$result = ConvertTo-CleanBranchName -Name $Description |
||||
|
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 |
||||
|
return [string]::Join('-', $fallbackWords) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# Generate branch name |
||||
|
if ($ShortName) { |
||||
|
# Use provided short name, just clean it up |
||||
|
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName |
||||
|
} else { |
||||
|
# Generate from description with smart filtering |
||||
|
$branchSuffix = Get-BranchName -Description $featureDesc |
||||
|
} |
||||
|
|
||||
|
# Determine branch number |
||||
|
if ($Number -eq 0) { |
||||
|
if ($hasGit) { |
||||
|
# Check existing branches on remotes |
||||
|
$Number = Get-NextBranchNumber -SpecsDir $specsDir |
||||
|
} else { |
||||
|
# Fall back to local directory check |
||||
|
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$featureNum = ('{0:000}' -f $Number) |
||||
|
$branchName = "$featureNum-$branchSuffix" |
||||
|
|
||||
|
# GitHub enforces a 244-byte limit on branch names |
||||
|
# Validate and truncate if necessary |
||||
|
$maxBranchLength = 244 |
||||
|
if ($branchName.Length -gt $maxBranchLength) { |
||||
|
# Calculate how much we need to trim from suffix |
||||
|
# Account for: feature number (3) + hyphen (1) = 4 chars |
||||
|
$maxSuffixLength = $maxBranchLength - 4 |
||||
|
|
||||
|
# Truncate suffix |
||||
|
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) |
||||
|
# Remove trailing hyphen if truncation created one |
||||
|
$truncatedSuffix = $truncatedSuffix -replace '-$', '' |
||||
|
|
||||
|
$originalBranchName = $branchName |
||||
|
$branchName = "$featureNum-$truncatedSuffix" |
||||
|
|
||||
|
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" |
||||
|
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" |
||||
|
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" |
||||
|
} |
||||
|
|
||||
|
if ($hasGit) { |
||||
|
$branchCreated = $false |
||||
|
try { |
||||
|
git checkout -q -b $branchName 2>$null | Out-Null |
||||
|
if ($LASTEXITCODE -eq 0) { |
||||
|
$branchCreated = $true |
||||
|
} |
||||
|
} catch { |
||||
|
# Exception during git command |
||||
|
} |
||||
|
|
||||
|
if (-not $branchCreated) { |
||||
|
# Check if branch already exists |
||||
|
$existingBranch = git branch --list $branchName 2>$null |
||||
|
if ($existingBranch) { |
||||
|
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." |
||||
|
exit 1 |
||||
|
} else { |
||||
|
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." |
||||
|
exit 1 |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" |
||||
|
} |
||||
|
|
||||
|
$featureDir = Join-Path $specsDir $branchName |
||||
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null |
||||
|
|
||||
|
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot |
||||
|
$specFile = Join-Path $featureDir 'spec.md' |
||||
|
if ($template -and (Test-Path $template)) { |
||||
|
Copy-Item $template $specFile -Force |
||||
|
} else { |
||||
|
New-Item -ItemType File -Path $specFile | Out-Null |
||||
|
} |
||||
|
|
||||
|
# Set the SPECIFY_FEATURE environment variable for the current session |
||||
|
$env:SPECIFY_FEATURE = $branchName |
||||
|
|
||||
|
if ($Json) { |
||||
|
$obj = [PSCustomObject]@{ |
||||
|
BRANCH_NAME = $branchName |
||||
|
SPEC_FILE = $specFile |
||||
|
FEATURE_NUM = $featureNum |
||||
|
HAS_GIT = $hasGit |
||||
|
} |
||||
|
$obj | ConvertTo-Json -Compress |
||||
|
} else { |
||||
|
Write-Output "BRANCH_NAME: $branchName" |
||||
|
Write-Output "SPEC_FILE: $specFile" |
||||
|
Write-Output "FEATURE_NUM: $featureNum" |
||||
|
Write-Output "HAS_GIT: $hasGit" |
||||
|
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,61 @@ |
|||||
|
#!/usr/bin/env pwsh |
||||
|
# Setup implementation plan for a feature |
||||
|
|
||||
|
[CmdletBinding()] |
||||
|
param( |
||||
|
[switch]$Json, |
||||
|
[switch]$Help |
||||
|
) |
||||
|
|
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
|
||||
|
# Show help if requested |
||||
|
if ($Help) { |
||||
|
Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]" |
||||
|
Write-Output " -Json Output results in JSON format" |
||||
|
Write-Output " -Help Show this help message" |
||||
|
exit 0 |
||||
|
} |
||||
|
|
||||
|
# Load common functions |
||||
|
. "$PSScriptRoot/common.ps1" |
||||
|
|
||||
|
# Get all paths and variables from common functions |
||||
|
$paths = Get-FeaturePathsEnv |
||||
|
|
||||
|
# Check if we're on a proper feature branch (only for git repos) |
||||
|
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
# Ensure the feature directory exists |
||||
|
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null |
||||
|
|
||||
|
# Copy plan template if it exists, otherwise note it or create empty file |
||||
|
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT |
||||
|
if ($template -and (Test-Path $template)) { |
||||
|
Copy-Item $template $paths.IMPL_PLAN -Force |
||||
|
Write-Output "Copied plan template to $($paths.IMPL_PLAN)" |
||||
|
} else { |
||||
|
Write-Warning "Plan template not found" |
||||
|
# Create a basic plan file if template doesn't exist |
||||
|
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null |
||||
|
} |
||||
|
|
||||
|
# Output results |
||||
|
if ($Json) { |
||||
|
$result = [PSCustomObject]@{ |
||||
|
FEATURE_SPEC = $paths.FEATURE_SPEC |
||||
|
IMPL_PLAN = $paths.IMPL_PLAN |
||||
|
SPECS_DIR = $paths.FEATURE_DIR |
||||
|
BRANCH = $paths.CURRENT_BRANCH |
||||
|
HAS_GIT = $paths.HAS_GIT |
||||
|
} |
||||
|
$result | ConvertTo-Json -Compress |
||||
|
} else { |
||||
|
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" |
||||
|
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" |
||||
|
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)" |
||||
|
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" |
||||
|
Write-Output "HAS_GIT: $($paths.HAS_GIT)" |
||||
|
} |
||||
@ -0,0 +1,472 @@ |
|||||
|
#!/usr/bin/env pwsh |
||||
|
<#! |
||||
|
.SYNOPSIS |
||||
|
Update agent context files with information from plan.md (PowerShell version) |
||||
|
|
||||
|
.DESCRIPTION |
||||
|
Mirrors the behavior of scripts/bash/update-agent-context.sh: |
||||
|
1. Environment Validation |
||||
|
2. Plan Data Extraction |
||||
|
3. Agent File Management (create from template or update existing) |
||||
|
4. Content Generation (technology stack, recent changes, timestamp) |
||||
|
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, generic) |
||||
|
|
||||
|
.PARAMETER AgentType |
||||
|
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). |
||||
|
|
||||
|
.EXAMPLE |
||||
|
./update-agent-context.ps1 -AgentType claude |
||||
|
|
||||
|
.EXAMPLE |
||||
|
./update-agent-context.ps1 # Updates all existing agent files |
||||
|
|
||||
|
.NOTES |
||||
|
Relies on common helper functions in common.ps1 |
||||
|
#> |
||||
|
param( |
||||
|
[Parameter(Position=0)] |
||||
|
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','generic')] |
||||
|
[string]$AgentType |
||||
|
) |
||||
|
|
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
|
||||
|
# Import common helpers |
||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path |
||||
|
. (Join-Path $ScriptDir 'common.ps1') |
||||
|
|
||||
|
# Acquire environment paths |
||||
|
$envData = Get-FeaturePathsEnv |
||||
|
$REPO_ROOT = $envData.REPO_ROOT |
||||
|
$CURRENT_BRANCH = $envData.CURRENT_BRANCH |
||||
|
$HAS_GIT = $envData.HAS_GIT |
||||
|
$IMPL_PLAN = $envData.IMPL_PLAN |
||||
|
$NEW_PLAN = $IMPL_PLAN |
||||
|
|
||||
|
# Agent file paths |
||||
|
$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' |
||||
|
$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' |
||||
|
$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md' |
||||
|
$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' |
||||
|
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' |
||||
|
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' |
||||
|
$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md' |
||||
|
$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md' |
||||
|
$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md' |
||||
|
$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md' |
||||
|
$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' |
||||
|
$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md' |
||||
|
$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' |
||||
|
$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' |
||||
|
$TABNINE_FILE = Join-Path $REPO_ROOT 'TABNINE.md' |
||||
|
$KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md' |
||||
|
$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' |
||||
|
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' |
||||
|
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' |
||||
|
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' |
||||
|
|
||||
|
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' |
||||
|
|
||||
|
# Parsed plan data placeholders |
||||
|
$script:NEW_LANG = '' |
||||
|
$script:NEW_FRAMEWORK = '' |
||||
|
$script:NEW_DB = '' |
||||
|
$script:NEW_PROJECT_TYPE = '' |
||||
|
|
||||
|
function Write-Info { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$Message |
||||
|
) |
||||
|
Write-Host "INFO: $Message" |
||||
|
} |
||||
|
|
||||
|
function Write-Success { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$Message |
||||
|
) |
||||
|
Write-Host "$([char]0x2713) $Message" |
||||
|
} |
||||
|
|
||||
|
function Write-WarningMsg { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$Message |
||||
|
) |
||||
|
Write-Warning $Message |
||||
|
} |
||||
|
|
||||
|
function Write-Err { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$Message |
||||
|
) |
||||
|
Write-Host "ERROR: $Message" -ForegroundColor Red |
||||
|
} |
||||
|
|
||||
|
function Validate-Environment { |
||||
|
if (-not $CURRENT_BRANCH) { |
||||
|
Write-Err 'Unable to determine current feature' |
||||
|
if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' } |
||||
|
exit 1 |
||||
|
} |
||||
|
if (-not (Test-Path $NEW_PLAN)) { |
||||
|
Write-Err "No plan.md found at $NEW_PLAN" |
||||
|
Write-Info 'Ensure you are working on a feature with a corresponding spec directory' |
||||
|
if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' } |
||||
|
exit 1 |
||||
|
} |
||||
|
if (-not (Test-Path $TEMPLATE_FILE)) { |
||||
|
Write-Err "Template file not found at $TEMPLATE_FILE" |
||||
|
Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.' |
||||
|
exit 1 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Extract-PlanField { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$FieldPattern, |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$PlanFile |
||||
|
) |
||||
|
if (-not (Test-Path $PlanFile)) { return '' } |
||||
|
# Lines like **Language/Version**: Python 3.12 |
||||
|
$regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$" |
||||
|
Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object { |
||||
|
if ($_ -match $regex) { |
||||
|
$val = $Matches[1].Trim() |
||||
|
if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val } |
||||
|
} |
||||
|
} | Select-Object -First 1 |
||||
|
} |
||||
|
|
||||
|
function Parse-PlanData { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$PlanFile |
||||
|
) |
||||
|
if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false } |
||||
|
Write-Info "Parsing plan data from $PlanFile" |
||||
|
$script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile |
||||
|
$script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile |
||||
|
$script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile |
||||
|
$script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile |
||||
|
|
||||
|
if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' } |
||||
|
if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" } |
||||
|
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" } |
||||
|
if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" } |
||||
|
return $true |
||||
|
} |
||||
|
|
||||
|
function Format-TechnologyStack { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$false)] |
||||
|
[string]$Lang, |
||||
|
[Parameter(Mandatory=$false)] |
||||
|
[string]$Framework |
||||
|
) |
||||
|
$parts = @() |
||||
|
if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang } |
||||
|
if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework } |
||||
|
if (-not $parts) { return '' } |
||||
|
return ($parts -join ' + ') |
||||
|
} |
||||
|
|
||||
|
function Get-ProjectStructure { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$false)] |
||||
|
[string]$ProjectType |
||||
|
) |
||||
|
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" } |
||||
|
} |
||||
|
|
||||
|
function Get-CommandsForLanguage { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$false)] |
||||
|
[string]$Lang |
||||
|
) |
||||
|
switch -Regex ($Lang) { |
||||
|
'Python' { return "cd src; pytest; ruff check ." } |
||||
|
'Rust' { return "cargo test; cargo clippy" } |
||||
|
'JavaScript|TypeScript' { return "npm test; npm run lint" } |
||||
|
default { return "# Add commands for $Lang" } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Get-LanguageConventions { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$false)] |
||||
|
[string]$Lang |
||||
|
) |
||||
|
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' } |
||||
|
} |
||||
|
|
||||
|
function New-AgentFile { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$TargetFile, |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$ProjectName, |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[datetime]$Date |
||||
|
) |
||||
|
if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false } |
||||
|
$temp = New-TemporaryFile |
||||
|
Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force |
||||
|
|
||||
|
$projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE |
||||
|
$commands = Get-CommandsForLanguage -Lang $NEW_LANG |
||||
|
$languageConventions = Get-LanguageConventions -Lang $NEW_LANG |
||||
|
|
||||
|
$escaped_lang = $NEW_LANG |
||||
|
$escaped_framework = $NEW_FRAMEWORK |
||||
|
$escaped_branch = $CURRENT_BRANCH |
||||
|
|
||||
|
$content = Get-Content -LiteralPath $temp -Raw -Encoding utf8 |
||||
|
$content = $content -replace '\[PROJECT NAME\]',$ProjectName |
||||
|
$content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd') |
||||
|
|
||||
|
# Build the technology stack string safely |
||||
|
$techStackForTemplate = "" |
||||
|
if ($escaped_lang -and $escaped_framework) { |
||||
|
$techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)" |
||||
|
} elseif ($escaped_lang) { |
||||
|
$techStackForTemplate = "- $escaped_lang ($escaped_branch)" |
||||
|
} elseif ($escaped_framework) { |
||||
|
$techStackForTemplate = "- $escaped_framework ($escaped_branch)" |
||||
|
} |
||||
|
|
||||
|
$content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate |
||||
|
# For project structure we manually embed (keep newlines) |
||||
|
$escapedStructure = [Regex]::Escape($projectStructure) |
||||
|
$content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure |
||||
|
# Replace escaped newlines placeholder after all replacements |
||||
|
$content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands |
||||
|
$content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions |
||||
|
|
||||
|
# Build the recent changes string safely |
||||
|
$recentChangesForTemplate = "" |
||||
|
if ($escaped_lang -and $escaped_framework) { |
||||
|
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}" |
||||
|
} elseif ($escaped_lang) { |
||||
|
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}" |
||||
|
} elseif ($escaped_framework) { |
||||
|
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}" |
||||
|
} |
||||
|
|
||||
|
$content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate |
||||
|
# Convert literal \n sequences introduced by Escape to real newlines |
||||
|
$content = $content -replace '\\n',[Environment]::NewLine |
||||
|
|
||||
|
# Prepend Cursor frontmatter for .mdc files so rules are auto-included |
||||
|
if ($TargetFile -match '\.mdc$') { |
||||
|
$frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine |
||||
|
$content = $frontmatter + $content |
||||
|
} |
||||
|
|
||||
|
$parent = Split-Path -Parent $TargetFile |
||||
|
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } |
||||
|
Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 |
||||
|
Remove-Item $temp -Force |
||||
|
return $true |
||||
|
} |
||||
|
|
||||
|
function Update-ExistingAgentFile { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$TargetFile, |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[datetime]$Date |
||||
|
) |
||||
|
if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) } |
||||
|
|
||||
|
$techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK |
||||
|
$newTechEntries = @() |
||||
|
if ($techStack) { |
||||
|
$escapedTechStack = [Regex]::Escape($techStack) |
||||
|
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { |
||||
|
$newTechEntries += "- $techStack ($CURRENT_BRANCH)" |
||||
|
} |
||||
|
} |
||||
|
if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { |
||||
|
$escapedDB = [Regex]::Escape($NEW_DB) |
||||
|
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { |
||||
|
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)" |
||||
|
} |
||||
|
} |
||||
|
$newChangeEntry = '' |
||||
|
if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" } |
||||
|
elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" } |
||||
|
|
||||
|
$lines = Get-Content -LiteralPath $TargetFile -Encoding utf8 |
||||
|
$output = New-Object System.Collections.Generic.List[string] |
||||
|
$inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0 |
||||
|
|
||||
|
for ($i=0; $i -lt $lines.Count; $i++) { |
||||
|
$line = $lines[$i] |
||||
|
if ($line -eq '## Active Technologies') { |
||||
|
$output.Add($line) |
||||
|
$inTech = $true |
||||
|
continue |
||||
|
} |
||||
|
if ($inTech -and $line -match '^##\s') { |
||||
|
if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } |
||||
|
$output.Add($line); $inTech = $false; continue |
||||
|
} |
||||
|
if ($inTech -and [string]::IsNullOrWhiteSpace($line)) { |
||||
|
if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } |
||||
|
$output.Add($line); continue |
||||
|
} |
||||
|
if ($line -eq '## Recent Changes') { |
||||
|
$output.Add($line) |
||||
|
if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true } |
||||
|
$inChanges = $true |
||||
|
continue |
||||
|
} |
||||
|
if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue } |
||||
|
if ($inChanges -and $line -match '^- ') { |
||||
|
if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } |
||||
|
continue |
||||
|
} |
||||
|
if ($line -match '(\*\*)?Last updated(\*\*)?: .*\d{4}-\d{2}-\d{2}') { |
||||
|
$output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) |
||||
|
continue |
||||
|
} |
||||
|
$output.Add($line) |
||||
|
} |
||||
|
|
||||
|
# Post-loop check: if we're still in the Active Technologies section and haven't added new entries |
||||
|
if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) { |
||||
|
$newTechEntries | ForEach-Object { $output.Add($_) } |
||||
|
} |
||||
|
|
||||
|
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion |
||||
|
if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') { |
||||
|
$frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') |
||||
|
$output.InsertRange(0, $frontmatter) |
||||
|
} |
||||
|
|
||||
|
Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 |
||||
|
return $true |
||||
|
} |
||||
|
|
||||
|
function Update-AgentFile { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$TargetFile, |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$AgentName |
||||
|
) |
||||
|
if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false } |
||||
|
Write-Info "Updating $AgentName context file: $TargetFile" |
||||
|
$projectName = Split-Path $REPO_ROOT -Leaf |
||||
|
$date = Get-Date |
||||
|
|
||||
|
$dir = Split-Path -Parent $TargetFile |
||||
|
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } |
||||
|
|
||||
|
if (-not (Test-Path $TargetFile)) { |
||||
|
if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false } |
||||
|
} else { |
||||
|
try { |
||||
|
if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false } |
||||
|
} catch { |
||||
|
Write-Err "Cannot access or update existing file: $TargetFile. $_" |
||||
|
return $false |
||||
|
} |
||||
|
} |
||||
|
return $true |
||||
|
} |
||||
|
|
||||
|
function Update-SpecificAgent { |
||||
|
param( |
||||
|
[Parameter(Mandatory=$true)] |
||||
|
[string]$Type |
||||
|
) |
||||
|
switch ($Type) { |
||||
|
'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' } |
||||
|
'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' } |
||||
|
'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' } |
||||
|
'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' } |
||||
|
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' } |
||||
|
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } |
||||
|
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } |
||||
|
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } |
||||
|
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } |
||||
|
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } |
||||
|
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } |
||||
|
'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } |
||||
|
'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } |
||||
|
'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } |
||||
|
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } |
||||
|
'tabnine' { Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI' } |
||||
|
'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' } |
||||
|
'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } |
||||
|
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } |
||||
|
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } |
||||
|
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } |
||||
|
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } |
||||
|
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic'; return $false } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Update-AllExistingAgents { |
||||
|
$found = $false |
||||
|
$ok = $true |
||||
|
if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $TABNINE_FILE) { if (-not (Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true } |
||||
|
if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true } |
||||
|
if (-not $found) { |
||||
|
Write-Info 'No existing agent files found, creating default Claude file...' |
||||
|
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } |
||||
|
} |
||||
|
return $ok |
||||
|
} |
||||
|
|
||||
|
function Print-Summary { |
||||
|
Write-Host '' |
||||
|
Write-Info 'Summary of changes:' |
||||
|
if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" } |
||||
|
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } |
||||
|
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } |
||||
|
Write-Host '' |
||||
|
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]' |
||||
|
} |
||||
|
|
||||
|
function Main { |
||||
|
Validate-Environment |
||||
|
Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ===" |
||||
|
if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 } |
||||
|
$success = $true |
||||
|
if ($AgentType) { |
||||
|
Write-Info "Updating specific agent: $AgentType" |
||||
|
if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false } |
||||
|
} |
||||
|
else { |
||||
|
Write-Info 'No agent specified, updating all existing agent files...' |
||||
|
if (-not (Update-AllExistingAgents)) { $success = $false } |
||||
|
} |
||||
|
Print-Summary |
||||
|
if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 } |
||||
|
} |
||||
|
|
||||
|
Main |
||||
@ -0,0 +1,28 @@ |
|||||
|
# [PROJECT NAME] Development Guidelines |
||||
|
|
||||
|
Auto-generated from all feature plans. Last updated: [DATE] |
||||
|
|
||||
|
## Active Technologies |
||||
|
|
||||
|
[EXTRACTED FROM ALL PLAN.MD FILES] |
||||
|
|
||||
|
## Project Structure |
||||
|
|
||||
|
```text |
||||
|
[ACTUAL STRUCTURE FROM PLANS] |
||||
|
``` |
||||
|
|
||||
|
## Commands |
||||
|
|
||||
|
[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] |
||||
|
|
||||
|
## Code Style |
||||
|
|
||||
|
[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] |
||||
|
|
||||
|
## Recent Changes |
||||
|
|
||||
|
[LAST 3 FEATURES AND WHAT THEY ADDED] |
||||
|
|
||||
|
<!-- MANUAL ADDITIONS START --> |
||||
|
<!-- MANUAL ADDITIONS END --> |
||||
@ -0,0 +1,40 @@ |
|||||
|
# [CHECKLIST TYPE] Checklist: [FEATURE NAME] |
||||
|
|
||||
|
**Purpose**: [Brief description of what this checklist covers] |
||||
|
**Created**: [DATE] |
||||
|
**Feature**: [Link to spec.md or relevant documentation] |
||||
|
|
||||
|
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. |
||||
|
|
||||
|
<!-- |
||||
|
============================================================================ |
||||
|
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only. |
||||
|
|
||||
|
The /speckit.checklist command MUST replace these with actual items based on: |
||||
|
- User's specific checklist request |
||||
|
- Feature requirements from spec.md |
||||
|
- Technical context from plan.md |
||||
|
- Implementation details from tasks.md |
||||
|
|
||||
|
DO NOT keep these sample items in the generated checklist file. |
||||
|
============================================================================ |
||||
|
--> |
||||
|
|
||||
|
## [Category 1] |
||||
|
|
||||
|
- [ ] CHK001 First checklist item with clear action |
||||
|
- [ ] CHK002 Second checklist item |
||||
|
- [ ] CHK003 Third checklist item |
||||
|
|
||||
|
## [Category 2] |
||||
|
|
||||
|
- [ ] CHK004 Another category item |
||||
|
- [ ] CHK005 Item with specific criteria |
||||
|
- [ ] CHK006 Final item in this category |
||||
|
|
||||
|
## Notes |
||||
|
|
||||
|
- Check items off as completed: `[x]` |
||||
|
- Add comments or findings inline |
||||
|
- Link to relevant resources or documentation |
||||
|
- Items are numbered sequentially for easy reference |
||||
@ -0,0 +1,73 @@ |
|||||
|
# [PROJECT_NAME] Constitution |
||||
|
|
||||
|
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> |
||||
|
|
||||
|
## Core Principles |
||||
|
|
||||
|
### [PRINCIPLE_1_NAME] |
||||
|
|
||||
|
<!-- Example: I. Library-First --> |
||||
|
|
||||
|
[PRINCIPLE_1_DESCRIPTION] |
||||
|
|
||||
|
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> |
||||
|
|
||||
|
### [PRINCIPLE_2_NAME] |
||||
|
|
||||
|
<!-- Example: II. CLI Interface --> |
||||
|
|
||||
|
[PRINCIPLE_2_DESCRIPTION] |
||||
|
|
||||
|
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats --> |
||||
|
|
||||
|
### [PRINCIPLE_3_NAME] |
||||
|
|
||||
|
<!-- Example: III. Test-First (NON-NEGOTIABLE) --> |
||||
|
|
||||
|
[PRINCIPLE_3_DESCRIPTION] |
||||
|
|
||||
|
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced --> |
||||
|
|
||||
|
### [PRINCIPLE_4_NAME] |
||||
|
|
||||
|
<!-- Example: IV. Integration Testing --> |
||||
|
|
||||
|
[PRINCIPLE_4_DESCRIPTION] |
||||
|
|
||||
|
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas --> |
||||
|
|
||||
|
### [PRINCIPLE_5_NAME] |
||||
|
|
||||
|
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> |
||||
|
|
||||
|
[PRINCIPLE_5_DESCRIPTION] |
||||
|
|
||||
|
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles --> |
||||
|
|
||||
|
## [SECTION_2_NAME] |
||||
|
|
||||
|
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> |
||||
|
|
||||
|
[SECTION_2_CONTENT] |
||||
|
|
||||
|
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. --> |
||||
|
|
||||
|
## [SECTION_3_NAME] |
||||
|
|
||||
|
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. --> |
||||
|
|
||||
|
[SECTION_3_CONTENT] |
||||
|
|
||||
|
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. --> |
||||
|
|
||||
|
## Governance |
||||
|
|
||||
|
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan --> |
||||
|
|
||||
|
[GOVERNANCE_RULES] |
||||
|
|
||||
|
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance --> |
||||
|
|
||||
|
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] |
||||
|
|
||||
|
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 --> |
||||
@ -0,0 +1,105 @@ |
|||||
|
# Implementation Plan: [FEATURE] |
||||
|
|
||||
|
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] |
||||
|
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` |
||||
|
|
||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. |
||||
|
|
||||
|
## Summary |
||||
|
|
||||
|
[Extract from feature spec: primary requirement + technical approach from research] |
||||
|
|
||||
|
## Technical Context |
||||
|
|
||||
|
<!-- |
||||
|
ACTION REQUIRED: Replace the content in this section with the technical details |
||||
|
for the project. The structure here is presented in advisory capacity to guide |
||||
|
the iteration process. |
||||
|
--> |
||||
|
|
||||
|
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] |
||||
|
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] |
||||
|
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] |
||||
|
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] |
||||
|
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] |
||||
|
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] |
||||
|
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] |
||||
|
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] |
||||
|
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] |
||||
|
|
||||
|
## Constitution Check |
||||
|
|
||||
|
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ |
||||
|
|
||||
|
[Gates determined based on constitution file] |
||||
|
|
||||
|
## Project Structure |
||||
|
|
||||
|
### Documentation (this feature) |
||||
|
|
||||
|
```text |
||||
|
specs/[###-feature]/ |
||||
|
├── plan.md # This file (/speckit.plan command output) |
||||
|
├── research.md # Phase 0 output (/speckit.plan command) |
||||
|
├── data-model.md # Phase 1 output (/speckit.plan command) |
||||
|
├── quickstart.md # Phase 1 output (/speckit.plan command) |
||||
|
├── contracts/ # Phase 1 output (/speckit.plan command) |
||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) |
||||
|
``` |
||||
|
|
||||
|
### Source Code (repository root) |
||||
|
|
||||
|
<!-- |
||||
|
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout |
||||
|
for this feature. Delete unused options and expand the chosen structure with |
||||
|
real paths (e.g., apps/admin, packages/something). The delivered plan must |
||||
|
not include Option labels. |
||||
|
--> |
||||
|
|
||||
|
```text |
||||
|
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) |
||||
|
src/ |
||||
|
├── models/ |
||||
|
├── services/ |
||||
|
├── cli/ |
||||
|
└── lib/ |
||||
|
|
||||
|
tests/ |
||||
|
├── contract/ |
||||
|
├── integration/ |
||||
|
└── unit/ |
||||
|
|
||||
|
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) |
||||
|
backend/ |
||||
|
├── src/ |
||||
|
│ ├── models/ |
||||
|
│ ├── services/ |
||||
|
│ └── api/ |
||||
|
└── tests/ |
||||
|
|
||||
|
frontend/ |
||||
|
├── src/ |
||||
|
│ ├── components/ |
||||
|
│ ├── pages/ |
||||
|
│ └── services/ |
||||
|
└── tests/ |
||||
|
|
||||
|
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) |
||||
|
api/ |
||||
|
└── [same as backend above] |
||||
|
|
||||
|
ios/ or android/ |
||||
|
└── [platform-specific structure: feature modules, UI flows, platform tests] |
||||
|
``` |
||||
|
|
||||
|
**Structure Decision**: [Document the selected structure and reference the real |
||||
|
directories captured above] |
||||
|
|
||||
|
## Complexity Tracking |
||||
|
|
||||
|
> **Fill ONLY if Constitution Check has violations that must be justified** |
||||
|
|
||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because | |
||||
|
| -------------------------- | ------------------ | ------------------------------------ | |
||||
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | |
||||
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | |
||||
@ -0,0 +1,115 @@ |
|||||
|
# Feature Specification: [FEATURE NAME] |
||||
|
|
||||
|
**Feature Branch**: `[###-feature-name]` |
||||
|
**Created**: [DATE] |
||||
|
**Status**: Draft |
||||
|
**Input**: User description: "$ARGUMENTS" |
||||
|
|
||||
|
## User Scenarios & Testing _(mandatory)_ |
||||
|
|
||||
|
<!-- |
||||
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance. |
||||
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them, |
||||
|
you should still have a viable MVP (Minimum Viable Product) that delivers value. |
||||
|
|
||||
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical. |
||||
|
Think of each story as a standalone slice of functionality that can be: |
||||
|
- Developed independently |
||||
|
- Tested independently |
||||
|
- Deployed independently |
||||
|
- Demonstrated to users independently |
||||
|
--> |
||||
|
|
||||
|
### User Story 1 - [Brief Title] (Priority: P1) |
||||
|
|
||||
|
[Describe this user journey in plain language] |
||||
|
|
||||
|
**Why this priority**: [Explain the value and why it has this priority level] |
||||
|
|
||||
|
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] |
||||
|
|
||||
|
**Acceptance Scenarios**: |
||||
|
|
||||
|
1. **Given** [initial state], **When** [action], **Then** [expected outcome] |
||||
|
2. **Given** [initial state], **When** [action], **Then** [expected outcome] |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### User Story 2 - [Brief Title] (Priority: P2) |
||||
|
|
||||
|
[Describe this user journey in plain language] |
||||
|
|
||||
|
**Why this priority**: [Explain the value and why it has this priority level] |
||||
|
|
||||
|
**Independent Test**: [Describe how this can be tested independently] |
||||
|
|
||||
|
**Acceptance Scenarios**: |
||||
|
|
||||
|
1. **Given** [initial state], **When** [action], **Then** [expected outcome] |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### User Story 3 - [Brief Title] (Priority: P3) |
||||
|
|
||||
|
[Describe this user journey in plain language] |
||||
|
|
||||
|
**Why this priority**: [Explain the value and why it has this priority level] |
||||
|
|
||||
|
**Independent Test**: [Describe how this can be tested independently] |
||||
|
|
||||
|
**Acceptance Scenarios**: |
||||
|
|
||||
|
1. **Given** [initial state], **When** [action], **Then** [expected outcome] |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
[Add more user stories as needed, each with an assigned priority] |
||||
|
|
||||
|
### Edge Cases |
||||
|
|
||||
|
<!-- |
||||
|
ACTION REQUIRED: The content in this section represents placeholders. |
||||
|
Fill them out with the right edge cases. |
||||
|
--> |
||||
|
|
||||
|
- What happens when [boundary condition]? |
||||
|
- How does system handle [error scenario]? |
||||
|
|
||||
|
## Requirements _(mandatory)_ |
||||
|
|
||||
|
<!-- |
||||
|
ACTION REQUIRED: The content in this section represents placeholders. |
||||
|
Fill them out with the right functional requirements. |
||||
|
--> |
||||
|
|
||||
|
### Functional Requirements |
||||
|
|
||||
|
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] |
||||
|
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] |
||||
|
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] |
||||
|
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] |
||||
|
- **FR-005**: System MUST [behavior, e.g., "log all security events"] |
||||
|
|
||||
|
_Example of marking unclear requirements:_ |
||||
|
|
||||
|
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] |
||||
|
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] |
||||
|
|
||||
|
### Key Entities _(include if feature involves data)_ |
||||
|
|
||||
|
- **[Entity 1]**: [What it represents, key attributes without implementation] |
||||
|
- **[Entity 2]**: [What it represents, relationships to other entities] |
||||
|
|
||||
|
## Success Criteria _(mandatory)_ |
||||
|
|
||||
|
<!-- |
||||
|
ACTION REQUIRED: Define measurable success criteria. |
||||
|
These must be technology-agnostic and measurable. |
||||
|
--> |
||||
|
|
||||
|
### Measurable Outcomes |
||||
|
|
||||
|
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] |
||||
|
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] |
||||
|
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] |
||||
|
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] |
||||
@ -0,0 +1,250 @@ |
|||||
|
--- |
||||
|
description: 'Task list template for feature implementation' |
||||
|
--- |
||||
|
|
||||
|
# Tasks: [FEATURE NAME] |
||||
|
|
||||
|
**Input**: Design documents from `/specs/[###-feature-name]/` |
||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ |
||||
|
|
||||
|
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. |
||||
|
|
||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. |
||||
|
|
||||
|
## Format: `[ID] [P?] [Story] Description` |
||||
|
|
||||
|
- **[P]**: Can run in parallel (different files, no dependencies) |
||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) |
||||
|
- Include exact file paths in descriptions |
||||
|
|
||||
|
## Path Conventions |
||||
|
|
||||
|
- **Single project**: `src/`, `tests/` at repository root |
||||
|
- **Web app**: `backend/src/`, `frontend/src/` |
||||
|
- **Mobile**: `api/src/`, `ios/src/` or `android/src/` |
||||
|
- Paths shown below assume single project - adjust based on plan.md structure |
||||
|
|
||||
|
<!-- |
||||
|
============================================================================ |
||||
|
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only. |
||||
|
|
||||
|
The /speckit.tasks command MUST replace these with actual tasks based on: |
||||
|
- User stories from spec.md (with their priorities P1, P2, P3...) |
||||
|
- Feature requirements from plan.md |
||||
|
- Entities from data-model.md |
||||
|
- Endpoints from contracts/ |
||||
|
|
||||
|
Tasks MUST be organized by user story so each story can be: |
||||
|
- Implemented independently |
||||
|
- Tested independently |
||||
|
- Delivered as an MVP increment |
||||
|
|
||||
|
DO NOT keep these sample tasks in the generated tasks.md file. |
||||
|
============================================================================ |
||||
|
--> |
||||
|
|
||||
|
## Phase 1: Setup (Shared Infrastructure) |
||||
|
|
||||
|
**Purpose**: Project initialization and basic structure |
||||
|
|
||||
|
- [ ] T001 Create project structure per implementation plan |
||||
|
- [ ] T002 Initialize [language] project with [framework] dependencies |
||||
|
- [ ] T003 [P] Configure linting and formatting tools |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Phase 2: Foundational (Blocking Prerequisites) |
||||
|
|
||||
|
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented |
||||
|
|
||||
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete |
||||
|
|
||||
|
Examples of foundational tasks (adjust based on your project): |
||||
|
|
||||
|
- [ ] T004 Setup database schema and migrations framework |
||||
|
- [ ] T005 [P] Implement authentication/authorization framework |
||||
|
- [ ] T006 [P] Setup API routing and middleware structure |
||||
|
- [ ] T007 Create base models/entities that all stories depend on |
||||
|
- [ ] T008 Configure error handling and logging infrastructure |
||||
|
- [ ] T009 Setup environment configuration management |
||||
|
|
||||
|
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP |
||||
|
|
||||
|
**Goal**: [Brief description of what this story delivers] |
||||
|
|
||||
|
**Independent Test**: [How to verify this story works on its own] |
||||
|
|
||||
|
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ |
||||
|
|
||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** |
||||
|
|
||||
|
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test\_[name].py |
||||
|
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test\_[name].py |
||||
|
|
||||
|
### Implementation for User Story 1 |
||||
|
|
||||
|
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py |
||||
|
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py |
||||
|
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) |
||||
|
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py |
||||
|
- [ ] T016 [US1] Add validation and error handling |
||||
|
- [ ] T017 [US1] Add logging for user story 1 operations |
||||
|
|
||||
|
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Phase 4: User Story 2 - [Title] (Priority: P2) |
||||
|
|
||||
|
**Goal**: [Brief description of what this story delivers] |
||||
|
|
||||
|
**Independent Test**: [How to verify this story works on its own] |
||||
|
|
||||
|
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ |
||||
|
|
||||
|
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test\_[name].py |
||||
|
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test\_[name].py |
||||
|
|
||||
|
### Implementation for User Story 2 |
||||
|
|
||||
|
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py |
||||
|
- [ ] T021 [US2] Implement [Service] in src/services/[service].py |
||||
|
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py |
||||
|
- [ ] T023 [US2] Integrate with User Story 1 components (if needed) |
||||
|
|
||||
|
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Phase 5: User Story 3 - [Title] (Priority: P3) |
||||
|
|
||||
|
**Goal**: [Brief description of what this story delivers] |
||||
|
|
||||
|
**Independent Test**: [How to verify this story works on its own] |
||||
|
|
||||
|
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ |
||||
|
|
||||
|
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test\_[name].py |
||||
|
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test\_[name].py |
||||
|
|
||||
|
### Implementation for User Story 3 |
||||
|
|
||||
|
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py |
||||
|
- [ ] T027 [US3] Implement [Service] in src/services/[service].py |
||||
|
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py |
||||
|
|
||||
|
**Checkpoint**: All user stories should now be independently functional |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
[Add more user story phases as needed, following the same pattern] |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Phase N: Polish & Cross-Cutting Concerns |
||||
|
|
||||
|
**Purpose**: Improvements that affect multiple user stories |
||||
|
|
||||
|
- [ ] TXXX [P] Documentation updates in docs/ |
||||
|
- [ ] TXXX Code cleanup and refactoring |
||||
|
- [ ] TXXX Performance optimization across all stories |
||||
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ |
||||
|
- [ ] TXXX Security hardening |
||||
|
- [ ] TXXX Run quickstart.md validation |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Dependencies & Execution Order |
||||
|
|
||||
|
### Phase Dependencies |
||||
|
|
||||
|
- **Setup (Phase 1)**: No dependencies - can start immediately |
||||
|
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories |
||||
|
- **User Stories (Phase 3+)**: All depend on Foundational phase completion |
||||
|
- User stories can then proceed in parallel (if staffed) |
||||
|
- Or sequentially in priority order (P1 → P2 → P3) |
||||
|
- **Polish (Final Phase)**: Depends on all desired user stories being complete |
||||
|
|
||||
|
### User Story Dependencies |
||||
|
|
||||
|
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories |
||||
|
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable |
||||
|
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable |
||||
|
|
||||
|
### Within Each User Story |
||||
|
|
||||
|
- Tests (if included) MUST be written and FAIL before implementation |
||||
|
- Models before services |
||||
|
- Services before endpoints |
||||
|
- Core implementation before integration |
||||
|
- Story complete before moving to next priority |
||||
|
|
||||
|
### Parallel Opportunities |
||||
|
|
||||
|
- All Setup tasks marked [P] can run in parallel |
||||
|
- All Foundational tasks marked [P] can run in parallel (within Phase 2) |
||||
|
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) |
||||
|
- All tests for a user story marked [P] can run in parallel |
||||
|
- Models within a story marked [P] can run in parallel |
||||
|
- Different user stories can be worked on in parallel by different team members |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Parallel Example: User Story 1 |
||||
|
|
||||
|
```bash |
||||
|
# Launch all tests for User Story 1 together (if tests requested): |
||||
|
Task: "Contract test for [endpoint] in tests/contract/test_[name].py" |
||||
|
Task: "Integration test for [user journey] in tests/integration/test_[name].py" |
||||
|
|
||||
|
# Launch all models for User Story 1 together: |
||||
|
Task: "Create [Entity1] model in src/models/[entity1].py" |
||||
|
Task: "Create [Entity2] model in src/models/[entity2].py" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Implementation Strategy |
||||
|
|
||||
|
### MVP First (User Story 1 Only) |
||||
|
|
||||
|
1. Complete Phase 1: Setup |
||||
|
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) |
||||
|
3. Complete Phase 3: User Story 1 |
||||
|
4. **STOP and VALIDATE**: Test User Story 1 independently |
||||
|
5. Deploy/demo if ready |
||||
|
|
||||
|
### Incremental Delivery |
||||
|
|
||||
|
1. Complete Setup + Foundational → Foundation ready |
||||
|
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) |
||||
|
3. Add User Story 2 → Test independently → Deploy/Demo |
||||
|
4. Add User Story 3 → Test independently → Deploy/Demo |
||||
|
5. Each story adds value without breaking previous stories |
||||
|
|
||||
|
### Parallel Team Strategy |
||||
|
|
||||
|
With multiple developers: |
||||
|
|
||||
|
1. Team completes Setup + Foundational together |
||||
|
2. Once Foundational is done: |
||||
|
- Developer A: User Story 1 |
||||
|
- Developer B: User Story 2 |
||||
|
- Developer C: User Story 3 |
||||
|
3. Stories complete and integrate independently |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Notes |
||||
|
|
||||
|
- [P] tasks = different files, no dependencies |
||||
|
- [Story] label maps task to specific user story for traceability |
||||
|
- Each user story should be independently completable and testable |
||||
|
- Verify tests fail before implementing |
||||
|
- Commit after each task or logical group |
||||
|
- Stop at any checkpoint to validate story independently |
||||
|
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence |
||||
@ -1,4 +1,13 @@ |
|||||
{ |
{ |
||||
"editor.defaultFormatter": "esbenp.prettier-vscode", |
"chat.promptFilesRecommendations": { |
||||
"editor.formatOnSave": true |
"speckit.constitution": true, |
||||
|
"speckit.specify": true, |
||||
|
"speckit.plan": true, |
||||
|
"speckit.tasks": true, |
||||
|
"speckit.implement": true |
||||
|
}, |
||||
|
"chat.tools.terminal.autoApprove": { |
||||
|
".specify/scripts/bash/": true, |
||||
|
".specify/scripts/powershell/": true |
||||
|
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,82 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { CreateDistributionDto } from '@ghostfolio/common/dtos'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Query, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { DistributionService } from './distribution.service'; |
||||
|
|
||||
|
@Controller('distribution') |
||||
|
export class DistributionController { |
||||
|
public constructor( |
||||
|
private readonly distributionService: DistributionService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@HasPermission(permissions.createDistribution) |
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async createDistribution(@Body() data: CreateDistributionDto) { |
||||
|
return this.distributionService.createDistribution({ |
||||
|
userId: this.request.user.id, |
||||
|
data: { |
||||
|
partnershipId: data.partnershipId, |
||||
|
entityId: data.entityId, |
||||
|
type: data.type, |
||||
|
amount: data.amount, |
||||
|
date: data.date, |
||||
|
currency: data.currency, |
||||
|
taxWithheld: data.taxWithheld, |
||||
|
notes: data.notes |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
@HasPermission(permissions.readDistribution) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getDistributions( |
||||
|
@Query('entityId') entityId?: string, |
||||
|
@Query('partnershipId') partnershipId?: string, |
||||
|
@Query('type') type?: string, |
||||
|
@Query('startDate') startDate?: string, |
||||
|
@Query('endDate') endDate?: string, |
||||
|
@Query('groupBy') groupBy?: string |
||||
|
) { |
||||
|
return this.distributionService.getDistributions({ |
||||
|
userId: this.request.user.id, |
||||
|
entityId, |
||||
|
partnershipId, |
||||
|
type, |
||||
|
startDate, |
||||
|
endDate, |
||||
|
groupBy |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Delete(':distributionId') |
||||
|
@HasPermission(permissions.deleteDistribution) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async deleteDistribution( |
||||
|
@Param('distributionId') distributionId: string |
||||
|
) { |
||||
|
return this.distributionService.deleteDistribution({ |
||||
|
distributionId, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { DistributionController } from './distribution.controller'; |
||||
|
import { DistributionService } from './distribution.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [DistributionController], |
||||
|
exports: [DistributionService], |
||||
|
imports: [PrismaModule], |
||||
|
providers: [DistributionService] |
||||
|
}) |
||||
|
export class DistributionModule {} |
||||
@ -0,0 +1,221 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class DistributionService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async createDistribution({ |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
userId: string; |
||||
|
data: { |
||||
|
partnershipId?: string; |
||||
|
entityId: string; |
||||
|
type: string; |
||||
|
amount: number; |
||||
|
date: string; |
||||
|
currency: string; |
||||
|
taxWithheld?: number; |
||||
|
notes?: string; |
||||
|
}; |
||||
|
}) { |
||||
|
// Verify entity belongs to user
|
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: data.entityId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// If partnershipId provided, validate it belongs to user and date >= inception
|
||||
|
if (data.partnershipId) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: data.partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (new Date(data.date) < partnership.inceptionDate) { |
||||
|
throw new HttpException( |
||||
|
'Distribution date cannot be before partnership inception date', |
||||
|
StatusCodes.BAD_REQUEST |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.distribution.create({ |
||||
|
data: { |
||||
|
entity: { connect: { id: data.entityId } }, |
||||
|
partnership: data.partnershipId |
||||
|
? { connect: { id: data.partnershipId } } |
||||
|
: undefined, |
||||
|
type: data.type as any, |
||||
|
amount: data.amount, |
||||
|
date: new Date(data.date), |
||||
|
currency: data.currency, |
||||
|
taxWithheld: data.taxWithheld ?? 0, |
||||
|
notes: data.notes |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getDistributions({ |
||||
|
userId, |
||||
|
entityId, |
||||
|
partnershipId, |
||||
|
type, |
||||
|
startDate, |
||||
|
endDate, |
||||
|
groupBy |
||||
|
}: { |
||||
|
userId: string; |
||||
|
entityId?: string; |
||||
|
partnershipId?: string; |
||||
|
type?: string; |
||||
|
startDate?: string; |
||||
|
endDate?: string; |
||||
|
groupBy?: string; |
||||
|
}) { |
||||
|
// Build where clause — distributions belong to entities owned by user
|
||||
|
const userEntities = await this.prismaService.entity.findMany({ |
||||
|
where: { userId }, |
||||
|
select: { id: true } |
||||
|
}); |
||||
|
const entityIds = userEntities.map((e) => e.id); |
||||
|
|
||||
|
const where: any = { |
||||
|
entityId: entityId |
||||
|
? { in: entityIds.includes(entityId) ? [entityId] : [] } |
||||
|
: { in: entityIds } |
||||
|
}; |
||||
|
|
||||
|
if (partnershipId) { |
||||
|
where.partnershipId = partnershipId; |
||||
|
} |
||||
|
|
||||
|
if (type) { |
||||
|
where.type = type; |
||||
|
} |
||||
|
|
||||
|
if (startDate || endDate) { |
||||
|
where.date = {}; |
||||
|
if (startDate) where.date.gte = new Date(startDate); |
||||
|
if (endDate) where.date.lte = new Date(endDate); |
||||
|
} |
||||
|
|
||||
|
const distributions = await this.prismaService.distribution.findMany({ |
||||
|
where, |
||||
|
include: { |
||||
|
partnership: { select: { id: true, name: true } }, |
||||
|
entity: { select: { id: true, name: true } } |
||||
|
}, |
||||
|
orderBy: { date: 'desc' } |
||||
|
}); |
||||
|
|
||||
|
const mapped = distributions.map((d) => ({ |
||||
|
id: d.id, |
||||
|
partnershipId: d.partnershipId, |
||||
|
partnershipName: d.partnership?.name, |
||||
|
entityId: d.entityId, |
||||
|
entityName: d.entity.name, |
||||
|
type: d.type, |
||||
|
amount: Number(d.amount), |
||||
|
date: d.date.toISOString(), |
||||
|
currency: d.currency, |
||||
|
taxWithheld: Number(d.taxWithheld ?? 0), |
||||
|
netAmount: Number(d.amount) - Number(d.taxWithheld ?? 0), |
||||
|
notes: d.notes |
||||
|
})); |
||||
|
|
||||
|
// Build summary
|
||||
|
const totalGross = mapped.reduce((sum, d) => sum + d.amount, 0); |
||||
|
const totalTaxWithheld = mapped.reduce((sum, d) => sum + d.taxWithheld, 0); |
||||
|
const totalNet = totalGross - totalTaxWithheld; |
||||
|
|
||||
|
const byType: Record<string, number> = {}; |
||||
|
for (const d of mapped) { |
||||
|
byType[d.type] = (byType[d.type] || 0) + d.amount; |
||||
|
} |
||||
|
|
||||
|
const byPartnership: Record<string, { name: string; total: number }> = {}; |
||||
|
for (const d of mapped) { |
||||
|
if (d.partnershipId) { |
||||
|
if (!byPartnership[d.partnershipId]) { |
||||
|
byPartnership[d.partnershipId] = { |
||||
|
name: d.partnershipName ?? '', |
||||
|
total: 0 |
||||
|
}; |
||||
|
} |
||||
|
byPartnership[d.partnershipId].total += d.amount; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const byPeriod: Record<string, number> = {}; |
||||
|
if (groupBy) { |
||||
|
for (const d of mapped) { |
||||
|
const date = new Date(d.date); |
||||
|
let key: string; |
||||
|
|
||||
|
if (groupBy === 'MONTHLY') { |
||||
|
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; |
||||
|
} else if (groupBy === 'QUARTERLY') { |
||||
|
const quarter = Math.ceil((date.getMonth() + 1) / 3); |
||||
|
key = `${date.getFullYear()}-Q${quarter}`; |
||||
|
} else { |
||||
|
key = `${date.getFullYear()}`; |
||||
|
} |
||||
|
|
||||
|
byPeriod[key] = (byPeriod[key] || 0) + d.amount; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
distributions: mapped, |
||||
|
summary: { |
||||
|
totalGross: Math.round(totalGross * 100) / 100, |
||||
|
totalTaxWithheld: Math.round(totalTaxWithheld * 100) / 100, |
||||
|
totalNet: Math.round(totalNet * 100) / 100, |
||||
|
byType, |
||||
|
byPartnership, |
||||
|
byPeriod |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async deleteDistribution({ |
||||
|
distributionId, |
||||
|
userId |
||||
|
}: { |
||||
|
distributionId: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const distribution = await this.prismaService.distribution.findFirst({ |
||||
|
where: { id: distributionId }, |
||||
|
include: { entity: { select: { userId: true } } } |
||||
|
}); |
||||
|
|
||||
|
if (!distribution || distribution.entity.userId !== userId) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.distribution.delete({ |
||||
|
where: { id: distributionId } |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,140 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { |
||||
|
CreateEntityDto, |
||||
|
CreateOwnershipDto, |
||||
|
UpdateEntityDto |
||||
|
} from '@ghostfolio/common/dtos'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Put, |
||||
|
Query, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { EntityService } from './entity.service'; |
||||
|
|
||||
|
@Controller('entity') |
||||
|
export class EntityController { |
||||
|
public constructor( |
||||
|
private readonly entityService: EntityService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@HasPermission(permissions.createEntity) |
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async createEntity(@Body() data: CreateEntityDto) { |
||||
|
return this.entityService.createEntity({ |
||||
|
name: data.name, |
||||
|
type: data.type as any, |
||||
|
taxId: data.taxId, |
||||
|
user: { connect: { id: this.request.user.id } } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
@HasPermission(permissions.readEntity) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getEntities(@Query('type') type?: string) { |
||||
|
return this.entityService.getEntities({ |
||||
|
userId: this.request.user.id, |
||||
|
type |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get(':id') |
||||
|
@HasPermission(permissions.readEntity) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getEntityById(@Param('id') id: string) { |
||||
|
return this.entityService.getEntityById({ |
||||
|
entityId: id, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updateEntity) |
||||
|
@Put(':id') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async updateEntity( |
||||
|
@Param('id') id: string, |
||||
|
@Body() data: UpdateEntityDto |
||||
|
) { |
||||
|
return this.entityService.updateEntity({ |
||||
|
entityId: id, |
||||
|
userId: this.request.user.id, |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Delete(':id') |
||||
|
@HasPermission(permissions.deleteEntity) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async deleteEntity(@Param('id') id: string) { |
||||
|
return this.entityService.deleteEntity({ |
||||
|
entityId: id, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get(':id/portfolio') |
||||
|
@HasPermission(permissions.readEntity) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getEntityPortfolio(@Param('id') id: string) { |
||||
|
return this.entityService.getEntityPortfolio({ |
||||
|
entityId: id, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.createEntity) |
||||
|
@Post(':id/ownership') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async createOwnership( |
||||
|
@Param('id') id: string, |
||||
|
@Body() data: CreateOwnershipDto |
||||
|
) { |
||||
|
return this.entityService.createOwnership({ |
||||
|
entityId: id, |
||||
|
userId: this.request.user.id, |
||||
|
data: { |
||||
|
accountId: data.accountId, |
||||
|
ownershipPercent: data.ownershipPercent, |
||||
|
effectiveDate: data.effectiveDate, |
||||
|
acquisitionDate: data.acquisitionDate, |
||||
|
costBasis: data.costBasis |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get(':id/distributions') |
||||
|
@HasPermission(permissions.readDistribution) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getEntityDistributions(@Param('id') id: string) { |
||||
|
return this.entityService.getEntityDistributions({ |
||||
|
entityId: id, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get(':id/k-documents') |
||||
|
@HasPermission(permissions.readKDocument) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getEntityKDocuments(@Param('id') id: string) { |
||||
|
return this.entityService.getEntityKDocuments({ |
||||
|
entityId: id, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { EntityController } from './entity.controller'; |
||||
|
import { EntityService } from './entity.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [EntityController], |
||||
|
exports: [EntityService], |
||||
|
imports: [PrismaModule], |
||||
|
providers: [EntityService] |
||||
|
}) |
||||
|
export class EntityModule {} |
||||
@ -0,0 +1,511 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import type { |
||||
|
IEntityPortfolio, |
||||
|
IEntityWithRelations |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { Prisma } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class EntityService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async createEntity(data: Prisma.EntityCreateInput) { |
||||
|
return this.prismaService.entity.create({ data }); |
||||
|
} |
||||
|
|
||||
|
public async getEntities({ |
||||
|
userId, |
||||
|
type |
||||
|
}: { |
||||
|
userId: string; |
||||
|
type?: string; |
||||
|
}) { |
||||
|
const where: Prisma.EntityWhereInput = { userId }; |
||||
|
|
||||
|
if (type) { |
||||
|
where.type = type as any; |
||||
|
} |
||||
|
|
||||
|
const entities = await this.prismaService.entity.findMany({ |
||||
|
where, |
||||
|
include: { |
||||
|
_count: { |
||||
|
select: { |
||||
|
ownerships: true, |
||||
|
memberships: true |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
orderBy: { name: 'asc' } |
||||
|
}); |
||||
|
|
||||
|
return entities.map((entity) => ({ |
||||
|
id: entity.id, |
||||
|
name: entity.name, |
||||
|
type: entity.type, |
||||
|
taxId: entity.taxId, |
||||
|
ownershipsCount: entity._count.ownerships, |
||||
|
membershipsCount: entity._count.memberships, |
||||
|
createdAt: entity.createdAt.toISOString(), |
||||
|
updatedAt: entity.updatedAt.toISOString() |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
public async getEntityById({ |
||||
|
entityId, |
||||
|
userId |
||||
|
}: { |
||||
|
entityId: string; |
||||
|
userId: string; |
||||
|
}): Promise<IEntityWithRelations> { |
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: entityId, userId }, |
||||
|
include: { |
||||
|
ownerships: { |
||||
|
where: { endDate: null }, |
||||
|
include: { |
||||
|
account: true |
||||
|
} |
||||
|
}, |
||||
|
memberships: { |
||||
|
where: { endDate: null }, |
||||
|
include: { |
||||
|
partnership: { |
||||
|
include: { |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const ownerships = entity.ownerships.map((o) => ({ |
||||
|
id: o.id, |
||||
|
accountId: o.accountId, |
||||
|
accountName: o.account?.name ?? o.accountId, |
||||
|
ownershipPercent: Number(o.ownershipPercent), |
||||
|
effectiveDate: o.effectiveDate.toISOString(), |
||||
|
endDate: o.endDate?.toISOString(), |
||||
|
acquisitionDate: o.acquisitionDate?.toISOString(), |
||||
|
costBasis: o.costBasis ? Number(o.costBasis) : undefined, |
||||
|
allocatedValue: 0 // Placeholder — real value from portfolio aggregation
|
||||
|
})); |
||||
|
|
||||
|
const memberships = entity.memberships.map((m) => { |
||||
|
const latestNav = m.partnership.valuations[0]?.nav; |
||||
|
const allocatedNav = latestNav |
||||
|
? Number(latestNav) * (Number(m.ownershipPercent) / 100) |
||||
|
: 0; |
||||
|
|
||||
|
return { |
||||
|
id: m.id, |
||||
|
partnershipId: m.partnershipId, |
||||
|
partnershipName: m.partnership.name, |
||||
|
ownershipPercent: Number(m.ownershipPercent), |
||||
|
capitalCommitment: m.capitalCommitment |
||||
|
? Number(m.capitalCommitment) |
||||
|
: undefined, |
||||
|
capitalContributed: m.capitalContributed |
||||
|
? Number(m.capitalContributed) |
||||
|
: undefined, |
||||
|
classType: m.classType ?? undefined, |
||||
|
allocatedNav: allocatedNav |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const totalValue = memberships.reduce( |
||||
|
(sum, m) => sum + (m.allocatedNav ?? 0), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
id: entity.id, |
||||
|
name: entity.name, |
||||
|
type: entity.type, |
||||
|
taxId: entity.taxId ?? undefined, |
||||
|
ownerships, |
||||
|
memberships, |
||||
|
totalValue, |
||||
|
createdAt: entity.createdAt.toISOString(), |
||||
|
updatedAt: entity.updatedAt.toISOString() |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async updateEntity({ |
||||
|
entityId, |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
entityId: string; |
||||
|
userId: string; |
||||
|
data: { name?: string; taxId?: string }; |
||||
|
}) { |
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: entityId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.entity.update({ |
||||
|
where: { id: entityId }, |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async deleteEntity({ |
||||
|
entityId, |
||||
|
userId |
||||
|
}: { |
||||
|
entityId: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: entityId, userId }, |
||||
|
include: { |
||||
|
ownerships: { where: { endDate: null } }, |
||||
|
memberships: { where: { endDate: null } } |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (entity.ownerships.length > 0 || entity.memberships.length > 0) { |
||||
|
throw new HttpException( |
||||
|
'Entity has active relationships. Remove ownerships and memberships first.', |
||||
|
StatusCodes.CONFLICT |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.entity.delete({ |
||||
|
where: { id: entityId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async createOwnership({ |
||||
|
entityId, |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
entityId: string; |
||||
|
userId: string; |
||||
|
data: { |
||||
|
accountId: string; |
||||
|
ownershipPercent: number; |
||||
|
effectiveDate: string; |
||||
|
acquisitionDate?: string; |
||||
|
costBasis?: number; |
||||
|
}; |
||||
|
}) { |
||||
|
// Verify entity belongs to user
|
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: entityId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Verify account belongs to user
|
||||
|
const account = await this.prismaService.account.findFirst({ |
||||
|
where: { id: data.accountId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!account) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Validate total ownership <= 100%
|
||||
|
const existingOwnerships = await this.prismaService.ownership.findMany({ |
||||
|
where: { |
||||
|
accountId: data.accountId, |
||||
|
accountUserId: userId, |
||||
|
endDate: null |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const currentTotal = existingOwnerships.reduce( |
||||
|
(sum, o) => sum + Number(o.ownershipPercent), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
if (currentTotal + data.ownershipPercent > 100) { |
||||
|
throw new HttpException( |
||||
|
`Total ownership for account would exceed 100% (current: ${currentTotal}%, requested: ${data.ownershipPercent}%)`, |
||||
|
StatusCodes.BAD_REQUEST |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.ownership.create({ |
||||
|
data: { |
||||
|
entity: { connect: { id: entityId } }, |
||||
|
account: { |
||||
|
connect: { |
||||
|
id_userId: { |
||||
|
id: data.accountId, |
||||
|
userId |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
ownershipPercent: data.ownershipPercent, |
||||
|
effectiveDate: new Date(data.effectiveDate), |
||||
|
acquisitionDate: data.acquisitionDate |
||||
|
? new Date(data.acquisitionDate) |
||||
|
: undefined, |
||||
|
costBasis: data.costBasis |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getEntityPortfolio({ |
||||
|
entityId, |
||||
|
userId |
||||
|
}: { |
||||
|
entityId: string; |
||||
|
userId: string; |
||||
|
}): Promise<IEntityPortfolio> { |
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: entityId, userId }, |
||||
|
include: { |
||||
|
ownerships: { |
||||
|
where: { endDate: null }, |
||||
|
include: { account: true } |
||||
|
}, |
||||
|
memberships: { |
||||
|
where: { endDate: null }, |
||||
|
include: { |
||||
|
partnership: { |
||||
|
include: { |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const accounts = entity.ownerships.map((o) => ({ |
||||
|
accountId: o.accountId, |
||||
|
accountName: o.account?.name ?? o.accountId, |
||||
|
ownershipPercent: Number(o.ownershipPercent), |
||||
|
allocatedValue: 0 // Placeholder — requires portfolio calculation
|
||||
|
})); |
||||
|
|
||||
|
const partnerships = entity.memberships.map((m) => { |
||||
|
const latestNav = m.partnership.valuations[0]?.nav; |
||||
|
const allocatedNav = latestNav |
||||
|
? Number(latestNav) * (Number(m.ownershipPercent) / 100) |
||||
|
: 0; |
||||
|
|
||||
|
return { |
||||
|
partnershipId: m.partnershipId, |
||||
|
partnershipName: m.partnership.name, |
||||
|
ownershipPercent: Number(m.ownershipPercent), |
||||
|
allocatedNav |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const totalValue = |
||||
|
accounts.reduce((s, a) => s + a.allocatedValue, 0) + |
||||
|
partnerships.reduce((s, p) => s + p.allocatedNav, 0); |
||||
|
|
||||
|
return { |
||||
|
entityId: entity.id, |
||||
|
entityName: entity.name, |
||||
|
totalValue, |
||||
|
currency: 'USD', // Default; real implementation uses user's base currency
|
||||
|
accounts, |
||||
|
partnerships, |
||||
|
allocationByStructure: {}, |
||||
|
allocationByAssetClass: {} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async getEntityDistributions({ |
||||
|
entityId, |
||||
|
userId, |
||||
|
startDate, |
||||
|
endDate |
||||
|
}: { |
||||
|
entityId: string; |
||||
|
userId: string; |
||||
|
startDate?: string; |
||||
|
endDate?: string; |
||||
|
}) { |
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: entityId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const where: Prisma.DistributionWhereInput = { entityId }; |
||||
|
|
||||
|
if (startDate || endDate) { |
||||
|
where.date = {}; |
||||
|
if (startDate) { |
||||
|
where.date.gte = new Date(startDate); |
||||
|
} |
||||
|
if (endDate) { |
||||
|
where.date.lte = new Date(endDate); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const distributions = await this.prismaService.distribution.findMany({ |
||||
|
where, |
||||
|
include: { |
||||
|
partnership: { select: { name: true } } |
||||
|
}, |
||||
|
orderBy: { date: 'desc' } |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
distributions: distributions.map((d) => ({ |
||||
|
id: d.id, |
||||
|
partnershipId: d.partnershipId, |
||||
|
partnershipName: d.partnership?.name, |
||||
|
entityId: d.entityId, |
||||
|
type: d.type, |
||||
|
amount: Number(d.amount), |
||||
|
date: d.date.toISOString(), |
||||
|
currency: d.currency, |
||||
|
taxWithheld: d.taxWithheld ? Number(d.taxWithheld) : 0, |
||||
|
netAmount: |
||||
|
Number(d.amount) - (d.taxWithheld ? Number(d.taxWithheld) : 0), |
||||
|
notes: d.notes |
||||
|
})), |
||||
|
summary: { |
||||
|
totalGross: distributions.reduce((s, d) => s + Number(d.amount), 0), |
||||
|
totalTaxWithheld: distributions.reduce( |
||||
|
(s, d) => s + (d.taxWithheld ? Number(d.taxWithheld) : 0), |
||||
|
0 |
||||
|
), |
||||
|
totalNet: distributions.reduce( |
||||
|
(s, d) => |
||||
|
s + Number(d.amount) - (d.taxWithheld ? Number(d.taxWithheld) : 0), |
||||
|
0 |
||||
|
), |
||||
|
byType: {}, |
||||
|
byPartnership: {}, |
||||
|
byPeriod: {} |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async getEntityKDocuments({ |
||||
|
entityId, |
||||
|
userId, |
||||
|
taxYear |
||||
|
}: { |
||||
|
entityId: string; |
||||
|
userId: string; |
||||
|
taxYear?: number; |
||||
|
}) { |
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: entityId, userId }, |
||||
|
include: { |
||||
|
memberships: { |
||||
|
where: { endDate: null }, |
||||
|
select: { partnershipId: true, ownershipPercent: true } |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const partnershipIds = entity.memberships.map((m) => m.partnershipId); |
||||
|
|
||||
|
const where: Prisma.KDocumentWhereInput = { |
||||
|
partnershipId: { in: partnershipIds } |
||||
|
}; |
||||
|
|
||||
|
if (taxYear) { |
||||
|
where.taxYear = taxYear; |
||||
|
} |
||||
|
|
||||
|
const kDocuments = await this.prismaService.kDocument.findMany({ |
||||
|
where, |
||||
|
include: { |
||||
|
partnership: { select: { name: true } } |
||||
|
}, |
||||
|
orderBy: [{ taxYear: 'desc' }, { type: 'asc' }] |
||||
|
}); |
||||
|
|
||||
|
return kDocuments.map((kDoc) => { |
||||
|
const membership = entity.memberships.find( |
||||
|
(m) => m.partnershipId === kDoc.partnershipId |
||||
|
); |
||||
|
const ownershipPercent = membership |
||||
|
? Number(membership.ownershipPercent) |
||||
|
: 0; |
||||
|
|
||||
|
const data = kDoc.data as Record<string, number>; |
||||
|
const allocatedAmounts: Record<string, number> = {}; |
||||
|
|
||||
|
for (const [key, value] of Object.entries(data)) { |
||||
|
if (typeof value === 'number') { |
||||
|
allocatedAmounts[key] = value * (ownershipPercent / 100); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
kDocumentId: kDoc.id, |
||||
|
partnershipId: kDoc.partnershipId, |
||||
|
partnershipName: kDoc.partnership.name, |
||||
|
type: kDoc.type, |
||||
|
taxYear: kDoc.taxYear, |
||||
|
filingStatus: kDoc.filingStatus, |
||||
|
ownershipPercent, |
||||
|
allocatedAmounts |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { FamilyOfficeService } from './family-office.service'; |
||||
|
|
||||
|
@Controller('family-office') |
||||
|
export class FamilyOfficeController { |
||||
|
public constructor( |
||||
|
private readonly familyOfficeService: FamilyOfficeService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@Get('dashboard') |
||||
|
@HasPermission(permissions.readEntity) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getDashboard() { |
||||
|
return this.familyOfficeService.getDashboard({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get('report') |
||||
|
@HasPermission(permissions.readEntity) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getReport( |
||||
|
@Query('benchmarks') benchmarks?: string, |
||||
|
@Query('entityId') entityId?: string, |
||||
|
@Query('period') period?: string, |
||||
|
@Query('periodNumber') periodNumber?: string, |
||||
|
@Query('year') year?: string |
||||
|
) { |
||||
|
if (!period || !year) { |
||||
|
return { |
||||
|
error: 'period and year are required query parameters' |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const validPeriods = ['MONTHLY', 'QUARTERLY', 'YEARLY']; |
||||
|
|
||||
|
if (!validPeriods.includes(period)) { |
||||
|
return { |
||||
|
error: `period must be one of: ${validPeriods.join(', ')}` |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const yearNum = parseInt(year, 10); |
||||
|
const periodNum = periodNumber ? parseInt(periodNumber, 10) : undefined; |
||||
|
|
||||
|
const benchmarkIds = benchmarks |
||||
|
? benchmarks.split(',').map((b) => b.trim()) |
||||
|
: undefined; |
||||
|
|
||||
|
return this.familyOfficeService.generateReport({ |
||||
|
benchmarkIds, |
||||
|
entityId, |
||||
|
period: period as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', |
||||
|
periodNumber: periodNum, |
||||
|
userId: this.request.user.id, |
||||
|
year: yearNum |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
import { FamilyOfficeBenchmarkService } from '@ghostfolio/api/services/benchmark/family-office-benchmark.service'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { FamilyOfficeController } from './family-office.controller'; |
||||
|
import { FamilyOfficeService } from './family-office.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [FamilyOfficeController], |
||||
|
exports: [FamilyOfficeService], |
||||
|
imports: [PrismaModule], |
||||
|
providers: [FamilyOfficeBenchmarkService, FamilyOfficeService] |
||||
|
}) |
||||
|
export class FamilyOfficeModule {} |
||||
@ -0,0 +1,730 @@ |
|||||
|
import { FamilyOfficePerformanceCalculator } from '@ghostfolio/api/app/portfolio/calculator/family-office/performance-calculator'; |
||||
|
import { |
||||
|
BenchmarkComparison, |
||||
|
FamilyOfficeBenchmarkService |
||||
|
} from '@ghostfolio/api/services/benchmark/family-office-benchmark.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import type { |
||||
|
IFamilyOfficeDashboard, |
||||
|
IFamilyOfficeReport |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { HttpException, Injectable, Logger } from '@nestjs/common'; |
||||
|
import Big from 'big.js'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class FamilyOfficeService { |
||||
|
private readonly logger = new Logger(FamilyOfficeService.name); |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly familyOfficeBenchmarkService: FamilyOfficeBenchmarkService, |
||||
|
private readonly prismaService: PrismaService |
||||
|
) {} |
||||
|
|
||||
|
/** |
||||
|
* Generate a consolidated periodic report for a user's family office. |
||||
|
* Optionally scoped to a single entity. |
||||
|
*/ |
||||
|
public async generateReport({ |
||||
|
benchmarkIds, |
||||
|
entityId, |
||||
|
period, |
||||
|
periodNumber, |
||||
|
userId, |
||||
|
year |
||||
|
}: { |
||||
|
benchmarkIds?: string[]; |
||||
|
entityId?: string; |
||||
|
period: 'MONTHLY' | 'QUARTERLY' | 'YEARLY'; |
||||
|
periodNumber?: number; |
||||
|
userId: string; |
||||
|
year: number; |
||||
|
}): Promise<IFamilyOfficeReport> { |
||||
|
// Validate period parameters
|
||||
|
if (period === 'MONTHLY' && periodNumber !== undefined) { |
||||
|
if (periodNumber < 1 || periodNumber > 12) { |
||||
|
throw new HttpException( |
||||
|
'periodNumber must be between 1 and 12 for MONTHLY period', |
||||
|
StatusCodes.BAD_REQUEST |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (period === 'QUARTERLY' && periodNumber !== undefined) { |
||||
|
if (periodNumber < 1 || periodNumber > 4) { |
||||
|
throw new HttpException( |
||||
|
'periodNumber must be between 1 and 4 for QUARTERLY period', |
||||
|
StatusCodes.BAD_REQUEST |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (year < 1900 || year > 2100) { |
||||
|
throw new HttpException( |
||||
|
'year must be between 1900 and 2100', |
||||
|
StatusCodes.BAD_REQUEST |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const { endDate, startDate } = this.computePeriodDates({ |
||||
|
period, |
||||
|
periodNumber, |
||||
|
year |
||||
|
}); |
||||
|
|
||||
|
const ytdStart = new Date(year, 0, 1); |
||||
|
|
||||
|
// Determine report title
|
||||
|
let periodLabel: string; |
||||
|
|
||||
|
if (period === 'MONTHLY') { |
||||
|
const monthNames = [ |
||||
|
'January', |
||||
|
'February', |
||||
|
'March', |
||||
|
'April', |
||||
|
'May', |
||||
|
'June', |
||||
|
'July', |
||||
|
'August', |
||||
|
'September', |
||||
|
'October', |
||||
|
'November', |
||||
|
'December' |
||||
|
]; |
||||
|
periodLabel = `${monthNames[(periodNumber ?? 1) - 1]} ${year}`; |
||||
|
} else if (period === 'QUARTERLY') { |
||||
|
periodLabel = `Q${periodNumber ?? 1} ${year}`; |
||||
|
} else { |
||||
|
periodLabel = `${year}`; |
||||
|
} |
||||
|
|
||||
|
// Entity scope
|
||||
|
let entityInfo: { id: string; name: string } | undefined; |
||||
|
|
||||
|
if (entityId) { |
||||
|
const entity = await this.prismaService.entity.findFirst({ |
||||
|
where: { id: entityId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!entity) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
entityInfo = { id: entity.id, name: entity.name }; |
||||
|
} |
||||
|
|
||||
|
// Get all user partnerships (scoped by entity if provided)
|
||||
|
const partnerships = await this.getUserPartnerships({ |
||||
|
entityId, |
||||
|
userId |
||||
|
}); |
||||
|
|
||||
|
// Compute value at period start and end
|
||||
|
const totalValueStart = await this.computeAggregateNav({ |
||||
|
asOfDate: startDate, |
||||
|
partnerships |
||||
|
}); |
||||
|
|
||||
|
const totalValueEnd = await this.computeAggregateNav({ |
||||
|
asOfDate: endDate, |
||||
|
partnerships |
||||
|
}); |
||||
|
|
||||
|
const periodChange = new Big(totalValueEnd) |
||||
|
.minus(totalValueStart) |
||||
|
.toNumber(); |
||||
|
const periodChangePercent = |
||||
|
totalValueStart > 0 |
||||
|
? new Big(periodChange).div(totalValueStart).round(4).toNumber() |
||||
|
: 0; |
||||
|
|
||||
|
// YTD change
|
||||
|
const totalValueYtdStart = await this.computeAggregateNav({ |
||||
|
asOfDate: ytdStart, |
||||
|
partnerships |
||||
|
}); |
||||
|
|
||||
|
const ytdChange = new Big(totalValueEnd) |
||||
|
.minus(totalValueYtdStart) |
||||
|
.toNumber(); |
||||
|
const ytdChangePercent = |
||||
|
totalValueYtdStart > 0 |
||||
|
? new Big(ytdChange).div(totalValueYtdStart).round(4).toNumber() |
||||
|
: 0; |
||||
|
|
||||
|
// Asset allocation by asset type across all partnership assets
|
||||
|
const assetAllocation = await this.computeAssetAllocation({ |
||||
|
partnerships |
||||
|
}); |
||||
|
|
||||
|
// Partnership performance
|
||||
|
const partnershipPerformance = await this.computePartnershipPerformance({ |
||||
|
endDate, |
||||
|
partnerships, |
||||
|
startDate, |
||||
|
userId |
||||
|
}); |
||||
|
|
||||
|
// Distribution summary for the period
|
||||
|
const distributionSummary = await this.computeDistributionSummary({ |
||||
|
endDate, |
||||
|
entityId, |
||||
|
startDate, |
||||
|
userId |
||||
|
}); |
||||
|
|
||||
|
// Benchmark comparisons
|
||||
|
let benchmarkComparisons: BenchmarkComparison[] | undefined; |
||||
|
|
||||
|
if (benchmarkIds && benchmarkIds.length > 0) { |
||||
|
// Compute overall portfolio return for the period
|
||||
|
const overallReturn = periodChangePercent; |
||||
|
|
||||
|
benchmarkComparisons = |
||||
|
await this.familyOfficeBenchmarkService.computeBenchmarkComparisons({ |
||||
|
benchmarkIds, |
||||
|
endDate, |
||||
|
partnershipReturn: overallReturn, |
||||
|
startDate |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
assetAllocation, |
||||
|
benchmarkComparisons, |
||||
|
distributionSummary, |
||||
|
entity: entityInfo, |
||||
|
partnershipPerformance, |
||||
|
period: { |
||||
|
end: endDate.toISOString().split('T')[0], |
||||
|
start: startDate.toISOString().split('T')[0] |
||||
|
}, |
||||
|
reportTitle: `${periodLabel} Family Office Report`, |
||||
|
summary: { |
||||
|
periodChange: Math.round(periodChange * 100) / 100, |
||||
|
periodChangePercent, |
||||
|
totalValueEnd: Math.round(totalValueEnd * 100) / 100, |
||||
|
totalValueStart: Math.round(totalValueStart * 100) / 100, |
||||
|
ytdChangePercent |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get consolidated dashboard data for the family office. |
||||
|
*/ |
||||
|
public async getDashboard({ |
||||
|
userId |
||||
|
}: { |
||||
|
userId: string; |
||||
|
}): Promise<IFamilyOfficeDashboard> { |
||||
|
// Count entities and partnerships
|
||||
|
const entities = await this.prismaService.entity.findMany({ |
||||
|
where: { userId }, |
||||
|
include: { |
||||
|
memberships: { |
||||
|
where: { endDate: null }, |
||||
|
include: { |
||||
|
partnership: { |
||||
|
include: { |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
}, |
||||
|
assets: { |
||||
|
include: { |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const partnerships = await this.prismaService.partnership.findMany({ |
||||
|
where: { userId } |
||||
|
}); |
||||
|
|
||||
|
// Compute AUM by entity
|
||||
|
const allocationByEntity: IFamilyOfficeDashboard['allocationByEntity'] = []; |
||||
|
let totalAum = new Big(0); |
||||
|
|
||||
|
for (const entity of entities) { |
||||
|
let entityValue = new Big(0); |
||||
|
|
||||
|
for (const membership of entity.memberships) { |
||||
|
const latestNav = membership.partnership.valuations[0] |
||||
|
? Number(membership.partnership.valuations[0].nav) |
||||
|
: 0; |
||||
|
const ownershipPct = Number(membership.ownershipPercent); |
||||
|
const allocatedValue = new Big(latestNav).times(ownershipPct).div(100); |
||||
|
entityValue = entityValue.plus(allocatedValue); |
||||
|
} |
||||
|
|
||||
|
if (entityValue.gt(0)) { |
||||
|
allocationByEntity.push({ |
||||
|
entityId: entity.id, |
||||
|
entityName: entity.name, |
||||
|
percentage: 0, // Will compute after totals
|
||||
|
value: entityValue.round(2).toNumber() |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
totalAum = totalAum.plus(entityValue); |
||||
|
} |
||||
|
|
||||
|
// Compute percentages
|
||||
|
const totalAumNumber = totalAum.round(2).toNumber(); |
||||
|
|
||||
|
for (const allocation of allocationByEntity) { |
||||
|
allocation.percentage = |
||||
|
totalAumNumber > 0 |
||||
|
? new Big(allocation.value) |
||||
|
.div(totalAumNumber) |
||||
|
.times(100) |
||||
|
.round(1) |
||||
|
.toNumber() |
||||
|
: 0; |
||||
|
} |
||||
|
|
||||
|
// Allocation by asset class — from partnership assets
|
||||
|
const allAssets = await this.prismaService.partnershipAsset.findMany({ |
||||
|
where: { |
||||
|
partnership: { userId } |
||||
|
}, |
||||
|
include: { |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const assetClassMap = new Map<string, Big>(); |
||||
|
|
||||
|
for (const asset of allAssets) { |
||||
|
const value = asset.valuations[0] |
||||
|
? new Big(Number(asset.valuations[0].value)) |
||||
|
: asset.currentValue |
||||
|
? new Big(Number(asset.currentValue)) |
||||
|
: new Big(0); |
||||
|
const assetType = asset.assetType; |
||||
|
const current = assetClassMap.get(assetType) ?? new Big(0); |
||||
|
assetClassMap.set(assetType, current.plus(value)); |
||||
|
} |
||||
|
|
||||
|
const allocationByAssetClass: IFamilyOfficeDashboard['allocationByAssetClass'] = |
||||
|
[]; |
||||
|
const totalAssetValue = Array.from(assetClassMap.values()).reduce( |
||||
|
(sum, v) => sum.plus(v), |
||||
|
new Big(0) |
||||
|
); |
||||
|
|
||||
|
for (const [assetClass, value] of assetClassMap) { |
||||
|
allocationByAssetClass.push({ |
||||
|
assetClass, |
||||
|
percentage: totalAssetValue.gt(0) |
||||
|
? value.div(totalAssetValue).times(100).round(1).toNumber() |
||||
|
: 0, |
||||
|
value: value.round(2).toNumber() |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Allocation by structure type (entity type)
|
||||
|
const structureMap = new Map<string, Big>(); |
||||
|
|
||||
|
for (const entity of entities) { |
||||
|
let entityValue = new Big(0); |
||||
|
|
||||
|
for (const membership of entity.memberships) { |
||||
|
const latestNav = membership.partnership.valuations[0] |
||||
|
? Number(membership.partnership.valuations[0].nav) |
||||
|
: 0; |
||||
|
const ownershipPct = Number(membership.ownershipPercent); |
||||
|
entityValue = entityValue.plus( |
||||
|
new Big(latestNav).times(ownershipPct).div(100) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const current = structureMap.get(entity.type) ?? new Big(0); |
||||
|
structureMap.set(entity.type, current.plus(entityValue)); |
||||
|
} |
||||
|
|
||||
|
const allocationByStructure: IFamilyOfficeDashboard['allocationByStructure'] = |
||||
|
[]; |
||||
|
|
||||
|
for (const [structureType, value] of structureMap) { |
||||
|
if (value.gt(0)) { |
||||
|
allocationByStructure.push({ |
||||
|
percentage: totalAum.gt(0) |
||||
|
? value.div(totalAum).times(100).round(1).toNumber() |
||||
|
: 0, |
||||
|
structureType, |
||||
|
value: value.round(2).toNumber() |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Recent distributions (last 5)
|
||||
|
const userEntityIds = entities.map((e) => e.id); |
||||
|
|
||||
|
const recentDistributions = await this.prismaService.distribution.findMany({ |
||||
|
include: { |
||||
|
partnership: { select: { name: true } } |
||||
|
}, |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 5, |
||||
|
where: { |
||||
|
entityId: { in: userEntityIds } |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// K-document status for current tax year
|
||||
|
const currentYear = new Date().getFullYear(); |
||||
|
|
||||
|
const kDocuments = await this.prismaService.kDocument.findMany({ |
||||
|
where: { |
||||
|
partnership: { userId }, |
||||
|
taxYear: currentYear |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const kDocumentStatus = { |
||||
|
draft: kDocuments.filter((k) => k.filingStatus === 'DRAFT').length, |
||||
|
estimated: kDocuments.filter((k) => k.filingStatus === 'ESTIMATED') |
||||
|
.length, |
||||
|
final: kDocuments.filter((k) => k.filingStatus === 'FINAL').length, |
||||
|
taxYear: currentYear, |
||||
|
total: kDocuments.length |
||||
|
}; |
||||
|
|
||||
|
return { |
||||
|
allocationByAssetClass, |
||||
|
allocationByEntity, |
||||
|
allocationByStructure, |
||||
|
currency: 'USD', |
||||
|
entitiesCount: entities.length, |
||||
|
kDocumentStatus, |
||||
|
partnershipsCount: partnerships.length, |
||||
|
recentDistributions: recentDistributions.map((d) => ({ |
||||
|
amount: Number(d.amount), |
||||
|
date: d.date.toISOString().split('T')[0], |
||||
|
id: d.id, |
||||
|
partnershipName: d.partnership?.name ?? 'N/A', |
||||
|
type: d.type |
||||
|
})), |
||||
|
totalAum: totalAumNumber |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// --- Private helpers ---
|
||||
|
|
||||
|
private computePeriodDates({ |
||||
|
period, |
||||
|
periodNumber, |
||||
|
year |
||||
|
}: { |
||||
|
period: 'MONTHLY' | 'QUARTERLY' | 'YEARLY'; |
||||
|
periodNumber?: number; |
||||
|
year: number; |
||||
|
}): { endDate: Date; startDate: Date } { |
||||
|
if (period === 'MONTHLY') { |
||||
|
const month = (periodNumber ?? 1) - 1; // 0-indexed
|
||||
|
const startDate = new Date(year, month, 1); |
||||
|
const endDate = new Date(year, month + 1, 0); // Last day of month
|
||||
|
return { endDate, startDate }; |
||||
|
} |
||||
|
|
||||
|
if (period === 'QUARTERLY') { |
||||
|
const quarter = periodNumber ?? 1; |
||||
|
const startMonth = (quarter - 1) * 3; |
||||
|
const startDate = new Date(year, startMonth, 1); |
||||
|
const endDate = new Date(year, startMonth + 3, 0); |
||||
|
return { endDate, startDate }; |
||||
|
} |
||||
|
|
||||
|
// YEARLY
|
||||
|
const startDate = new Date(year, 0, 1); |
||||
|
const endDate = new Date(year, 11, 31); |
||||
|
return { endDate, startDate }; |
||||
|
} |
||||
|
|
||||
|
private async getUserPartnerships({ |
||||
|
entityId, |
||||
|
userId |
||||
|
}: { |
||||
|
entityId?: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
if (entityId) { |
||||
|
// Get partnerships where this entity is a member
|
||||
|
const memberships = |
||||
|
await this.prismaService.partnershipMembership.findMany({ |
||||
|
include: { |
||||
|
partnership: true |
||||
|
}, |
||||
|
where: { |
||||
|
endDate: null, |
||||
|
entityId, |
||||
|
partnership: { userId } |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return memberships.map((m) => m.partnership); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.partnership.findMany({ |
||||
|
where: { userId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private async computeAggregateNav({ |
||||
|
asOfDate, |
||||
|
partnerships |
||||
|
}: { |
||||
|
asOfDate: Date; |
||||
|
partnerships: { id: string }[]; |
||||
|
}): Promise<number> { |
||||
|
let total = new Big(0); |
||||
|
|
||||
|
for (const partnership of partnerships) { |
||||
|
const valuation = await this.prismaService.partnershipValuation.findFirst( |
||||
|
{ |
||||
|
orderBy: { date: 'desc' }, |
||||
|
where: { |
||||
|
date: { lte: asOfDate }, |
||||
|
partnershipId: partnership.id |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
if (valuation) { |
||||
|
total = total.plus(Number(valuation.nav)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return total.toNumber(); |
||||
|
} |
||||
|
|
||||
|
private async computeAssetAllocation({ |
||||
|
partnerships |
||||
|
}: { |
||||
|
partnerships: { id: string }[]; |
||||
|
}): Promise<Record<string, { value: number; percentage: number }>> { |
||||
|
const partnershipIds = partnerships.map((p) => p.id); |
||||
|
|
||||
|
const assets = await this.prismaService.partnershipAsset.findMany({ |
||||
|
include: { |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
} |
||||
|
}, |
||||
|
where: { |
||||
|
partnershipId: { in: partnershipIds } |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const typeMap = new Map<string, Big>(); |
||||
|
let totalValue = new Big(0); |
||||
|
|
||||
|
for (const asset of assets) { |
||||
|
const value = asset.valuations[0] |
||||
|
? new Big(Number(asset.valuations[0].value)) |
||||
|
: asset.currentValue |
||||
|
? new Big(Number(asset.currentValue)) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const current = typeMap.get(asset.assetType) ?? new Big(0); |
||||
|
typeMap.set(asset.assetType, current.plus(value)); |
||||
|
totalValue = totalValue.plus(value); |
||||
|
} |
||||
|
|
||||
|
const result: Record<string, { value: number; percentage: number }> = {}; |
||||
|
|
||||
|
for (const [assetType, value] of typeMap) { |
||||
|
result[assetType] = { |
||||
|
percentage: totalValue.gt(0) |
||||
|
? value.div(totalValue).times(100).round(1).toNumber() |
||||
|
: 0, |
||||
|
value: value.round(2).toNumber() |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
private async computePartnershipPerformance({ |
||||
|
endDate, |
||||
|
partnerships, |
||||
|
startDate, |
||||
|
userId |
||||
|
}: { |
||||
|
endDate: Date; |
||||
|
partnerships: { id: string; name?: string }[]; |
||||
|
startDate: Date; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const results: IFamilyOfficeReport['partnershipPerformance'] = []; |
||||
|
|
||||
|
for (const partnership of partnerships) { |
||||
|
try { |
||||
|
const p = await this.prismaService.partnership.findFirst({ |
||||
|
include: { |
||||
|
distributions: true, |
||||
|
members: { |
||||
|
where: { endDate: null } |
||||
|
}, |
||||
|
valuations: { |
||||
|
orderBy: { date: 'asc' } |
||||
|
} |
||||
|
}, |
||||
|
where: { id: partnership.id, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!p) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const valuations = p.valuations |
||||
|
.filter((v) => v.date >= startDate && v.date <= endDate) |
||||
|
.map((v) => ({ date: v.date, nav: Number(v.nav) })); |
||||
|
|
||||
|
const totalContributions = p.members.reduce( |
||||
|
(sum, m) => sum + Number(m.capitalContributed || 0), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
const totalDistributions = p.distributions.reduce( |
||||
|
(sum, d) => sum + Number(d.amount), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
const latestNav = |
||||
|
valuations.length > 0 ? valuations[valuations.length - 1].nav : 0; |
||||
|
|
||||
|
// Cash flows for XIRR
|
||||
|
const cashFlows = [ |
||||
|
...(totalContributions > 0 |
||||
|
? [{ amount: -totalContributions, date: p.inceptionDate }] |
||||
|
: []), |
||||
|
...p.distributions.map((d) => ({ |
||||
|
amount: Number(d.amount), |
||||
|
date: d.date |
||||
|
})), |
||||
|
...(valuations.length > 0 |
||||
|
? [ |
||||
|
{ |
||||
|
amount: latestNav, |
||||
|
date: valuations[valuations.length - 1].date |
||||
|
} |
||||
|
] |
||||
|
: []) |
||||
|
]; |
||||
|
|
||||
|
const irr = FamilyOfficePerformanceCalculator.computeXIRR(cashFlows); |
||||
|
const tvpi = FamilyOfficePerformanceCalculator.computeTVPI( |
||||
|
totalDistributions, |
||||
|
latestNav, |
||||
|
totalContributions |
||||
|
); |
||||
|
const dpi = FamilyOfficePerformanceCalculator.computeDPI( |
||||
|
totalDistributions, |
||||
|
totalContributions |
||||
|
); |
||||
|
|
||||
|
// Period return via Modified Dietz if we have start/end valuations
|
||||
|
let periodReturn = 0; |
||||
|
|
||||
|
if (valuations.length >= 2) { |
||||
|
const periods = [ |
||||
|
{ |
||||
|
cashFlows: p.distributions |
||||
|
.filter((d) => d.date >= startDate && d.date <= endDate) |
||||
|
.map((d) => ({ amount: Number(d.amount), date: d.date })), |
||||
|
endDate: valuations[valuations.length - 1].date, |
||||
|
endValue: valuations[valuations.length - 1].nav, |
||||
|
startDate: valuations[0].date, |
||||
|
startValue: valuations[0].nav |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const returns = |
||||
|
FamilyOfficePerformanceCalculator.computeModifiedDietzReturns( |
||||
|
periods |
||||
|
); |
||||
|
periodReturn = returns[0]?.return ?? 0; |
||||
|
} |
||||
|
|
||||
|
results.push({ |
||||
|
dpi, |
||||
|
irr: irr !== null ? Math.round(irr * 10000) / 10000 : 0, |
||||
|
partnershipId: p.id, |
||||
|
partnershipName: p.name, |
||||
|
periodReturn, |
||||
|
tvpi |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
this.logger.warn( |
||||
|
`Failed to compute performance for partnership ${partnership.id}: ${error.message}` |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return results; |
||||
|
} |
||||
|
|
||||
|
private async computeDistributionSummary({ |
||||
|
endDate, |
||||
|
entityId, |
||||
|
startDate, |
||||
|
userId |
||||
|
}: { |
||||
|
endDate: Date; |
||||
|
entityId?: string; |
||||
|
startDate: Date; |
||||
|
userId: string; |
||||
|
}): Promise<IFamilyOfficeReport['distributionSummary']> { |
||||
|
const userEntities = await this.prismaService.entity.findMany({ |
||||
|
select: { id: true }, |
||||
|
where: { userId } |
||||
|
}); |
||||
|
const entityIds = entityId ? [entityId] : userEntities.map((e) => e.id); |
||||
|
|
||||
|
const distributions = await this.prismaService.distribution.findMany({ |
||||
|
where: { |
||||
|
date: { |
||||
|
gte: startDate, |
||||
|
lte: endDate |
||||
|
}, |
||||
|
entityId: { in: entityIds } |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const periodTotal = distributions.reduce( |
||||
|
(sum, d) => sum + Number(d.amount), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
const byType: Record<string, number> = {}; |
||||
|
|
||||
|
for (const d of distributions) { |
||||
|
byType[d.type] = (byType[d.type] || 0) + Number(d.amount); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
byType, |
||||
|
periodTotal: Math.round(periodTotal * 100) / 100 |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,94 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { |
||||
|
CreateKDocumentDto, |
||||
|
UpdateKDocumentDto |
||||
|
} from '@ghostfolio/common/dtos'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Get, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Put, |
||||
|
Query, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { KDocumentStatus, KDocumentType } from '@prisma/client'; |
||||
|
|
||||
|
import { KDocumentService } from './k-document.service'; |
||||
|
|
||||
|
@Controller('k-document') |
||||
|
export class KDocumentController { |
||||
|
public constructor( |
||||
|
private readonly kDocumentService: KDocumentService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@HasPermission(permissions.createKDocument) |
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async createKDocument(@Body() data: CreateKDocumentDto) { |
||||
|
return this.kDocumentService.createKDocument({ |
||||
|
data: data.data, |
||||
|
filingStatus: data.filingStatus, |
||||
|
partnershipId: data.partnershipId, |
||||
|
taxYear: data.taxYear, |
||||
|
type: data.type, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
@HasPermission(permissions.readKDocument) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getKDocuments( |
||||
|
@Query('filingStatus') filingStatus?: KDocumentStatus, |
||||
|
@Query('partnershipId') partnershipId?: string, |
||||
|
@Query('taxYear') taxYear?: string, |
||||
|
@Query('type') type?: KDocumentType |
||||
|
) { |
||||
|
return this.kDocumentService.getKDocuments({ |
||||
|
filingStatus, |
||||
|
partnershipId, |
||||
|
taxYear: taxYear ? parseInt(taxYear, 10) : undefined, |
||||
|
type, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updateKDocument) |
||||
|
@Put(':id') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async updateKDocument( |
||||
|
@Body() data: UpdateKDocumentDto, |
||||
|
@Param('id') id: string |
||||
|
) { |
||||
|
return this.kDocumentService.updateKDocument({ |
||||
|
data: data.data, |
||||
|
filingStatus: data.filingStatus, |
||||
|
kDocumentId: id, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updateKDocument) |
||||
|
@Post(':id/link-document') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async linkDocument( |
||||
|
@Body('documentId') documentId: string, |
||||
|
@Param('id') id: string |
||||
|
) { |
||||
|
return this.kDocumentService.linkDocument({ |
||||
|
documentId, |
||||
|
kDocumentId: id, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { UploadModule } from '../upload/upload.module'; |
||||
|
import { KDocumentController } from './k-document.controller'; |
||||
|
import { KDocumentService } from './k-document.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [KDocumentController], |
||||
|
exports: [KDocumentService], |
||||
|
imports: [PrismaModule, UploadModule], |
||||
|
providers: [KDocumentService] |
||||
|
}) |
||||
|
export class KDocumentModule {} |
||||
@ -0,0 +1,397 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import type { K1Data } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { KDocumentStatus, KDocumentType, Prisma } from '@prisma/client'; |
||||
|
import Big from 'big.js'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
const K1_DATA_FIELDS: (keyof K1Data)[] = [ |
||||
|
'ordinaryIncome', |
||||
|
'netRentalIncome', |
||||
|
'otherRentalIncome', |
||||
|
'guaranteedPayments', |
||||
|
'interestIncome', |
||||
|
'dividends', |
||||
|
'qualifiedDividends', |
||||
|
'royalties', |
||||
|
'capitalGainLossShortTerm', |
||||
|
'capitalGainLossLongTerm', |
||||
|
'unrecaptured1250Gain', |
||||
|
'section1231GainLoss', |
||||
|
'otherIncome', |
||||
|
'section179Deduction', |
||||
|
'otherDeductions', |
||||
|
'selfEmploymentEarnings', |
||||
|
'foreignTaxesPaid', |
||||
|
'alternativeMinimumTaxItems', |
||||
|
'distributionsCash', |
||||
|
'distributionsProperty' |
||||
|
]; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class KDocumentService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async createKDocument({ |
||||
|
data, |
||||
|
filingStatus, |
||||
|
partnershipId, |
||||
|
taxYear, |
||||
|
type, |
||||
|
userId |
||||
|
}: { |
||||
|
data: Record<string, number>; |
||||
|
filingStatus?: KDocumentStatus; |
||||
|
partnershipId: string; |
||||
|
taxYear: number; |
||||
|
type: KDocumentType; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
// Verify partnership exists and belongs to user
|
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { |
||||
|
id: partnershipId, |
||||
|
members: { |
||||
|
some: { |
||||
|
entity: { |
||||
|
userId |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Check inception year
|
||||
|
const inceptionYear = partnership.inceptionDate.getFullYear(); |
||||
|
|
||||
|
if (taxYear < inceptionYear) { |
||||
|
throw new HttpException( |
||||
|
`Tax year must be >= partnership inception year (${inceptionYear})`, |
||||
|
StatusCodes.BAD_REQUEST |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Check for duplicate
|
||||
|
const existing = await this.prismaService.kDocument.findUnique({ |
||||
|
where: { |
||||
|
partnershipId_type_taxYear: { |
||||
|
partnershipId, |
||||
|
taxYear, |
||||
|
type |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (existing) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.CONFLICT), |
||||
|
StatusCodes.CONFLICT |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Normalize data
|
||||
|
const normalizedData = this.normalizeK1Data(data); |
||||
|
|
||||
|
const kDocument = await this.prismaService.kDocument.create({ |
||||
|
data: { |
||||
|
data: normalizedData as unknown as Prisma.JsonObject, |
||||
|
filingStatus: filingStatus || KDocumentStatus.DRAFT, |
||||
|
partnershipId, |
||||
|
taxYear, |
||||
|
type |
||||
|
}, |
||||
|
include: { |
||||
|
partnership: { |
||||
|
select: { name: true } |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Compute allocations
|
||||
|
const allocations = await this.computeAllocations( |
||||
|
partnershipId, |
||||
|
normalizedData |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
...kDocument, |
||||
|
allocations, |
||||
|
partnershipName: kDocument.partnership.name |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async getKDocuments({ |
||||
|
filingStatus, |
||||
|
partnershipId, |
||||
|
taxYear, |
||||
|
type, |
||||
|
userId |
||||
|
}: { |
||||
|
filingStatus?: KDocumentStatus; |
||||
|
partnershipId?: string; |
||||
|
taxYear?: number; |
||||
|
type?: KDocumentType; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const where: Prisma.KDocumentWhereInput = { |
||||
|
partnership: { |
||||
|
members: { |
||||
|
some: { |
||||
|
entity: { |
||||
|
userId |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
if (partnershipId) { |
||||
|
where.partnershipId = partnershipId; |
||||
|
} |
||||
|
|
||||
|
if (taxYear) { |
||||
|
where.taxYear = taxYear; |
||||
|
} |
||||
|
|
||||
|
if (type) { |
||||
|
where.type = type; |
||||
|
} |
||||
|
|
||||
|
if (filingStatus) { |
||||
|
where.filingStatus = filingStatus; |
||||
|
} |
||||
|
|
||||
|
const kDocuments = await this.prismaService.kDocument.findMany({ |
||||
|
include: { |
||||
|
documentFile: { |
||||
|
select: { id: true, name: true } |
||||
|
}, |
||||
|
partnership: { |
||||
|
select: { name: true } |
||||
|
} |
||||
|
}, |
||||
|
orderBy: [{ taxYear: 'desc' }, { type: 'asc' }], |
||||
|
where |
||||
|
}); |
||||
|
|
||||
|
const results = []; |
||||
|
|
||||
|
for (const doc of kDocuments) { |
||||
|
const allocations = await this.computeAllocations( |
||||
|
doc.partnershipId, |
||||
|
doc.data as Record<string, number> |
||||
|
); |
||||
|
|
||||
|
results.push({ |
||||
|
allocations, |
||||
|
createdAt: doc.createdAt.toISOString(), |
||||
|
data: doc.data, |
||||
|
documentFileId: doc.documentFileId, |
||||
|
filingStatus: doc.filingStatus, |
||||
|
id: doc.id, |
||||
|
partnershipId: doc.partnershipId, |
||||
|
partnershipName: doc.partnership.name, |
||||
|
taxYear: doc.taxYear, |
||||
|
type: doc.type, |
||||
|
updatedAt: doc.updatedAt.toISOString() |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return results; |
||||
|
} |
||||
|
|
||||
|
public async updateKDocument({ |
||||
|
data, |
||||
|
filingStatus, |
||||
|
kDocumentId, |
||||
|
userId |
||||
|
}: { |
||||
|
data?: Record<string, number>; |
||||
|
filingStatus?: KDocumentStatus; |
||||
|
kDocumentId: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const existing = await this.prismaService.kDocument.findFirst({ |
||||
|
include: { |
||||
|
partnership: { |
||||
|
select: { name: true } |
||||
|
} |
||||
|
}, |
||||
|
where: { |
||||
|
id: kDocumentId, |
||||
|
partnership: { |
||||
|
members: { |
||||
|
some: { |
||||
|
entity: { |
||||
|
userId |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!existing) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Merge data if provided (spread operator for partial update)
|
||||
|
let updatedData = existing.data as Record<string, number>; |
||||
|
|
||||
|
if (data) { |
||||
|
updatedData = { ...updatedData, ...this.normalizeK1Data(data) }; |
||||
|
} |
||||
|
|
||||
|
const updateData: Prisma.KDocumentUpdateInput = { |
||||
|
data: updatedData as unknown as Prisma.JsonObject |
||||
|
}; |
||||
|
|
||||
|
if (filingStatus) { |
||||
|
updateData.filingStatus = filingStatus; |
||||
|
} |
||||
|
|
||||
|
const updated = await this.prismaService.kDocument.update({ |
||||
|
data: updateData, |
||||
|
include: { |
||||
|
partnership: { |
||||
|
select: { name: true } |
||||
|
} |
||||
|
}, |
||||
|
where: { id: kDocumentId } |
||||
|
}); |
||||
|
|
||||
|
const allocations = await this.computeAllocations( |
||||
|
updated.partnershipId, |
||||
|
updatedData |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
...updated, |
||||
|
allocations, |
||||
|
partnershipName: updated.partnership.name |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async linkDocument({ |
||||
|
documentId, |
||||
|
kDocumentId, |
||||
|
userId |
||||
|
}: { |
||||
|
documentId: string; |
||||
|
kDocumentId: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
// Verify K-document belongs to user
|
||||
|
const kDoc = await this.prismaService.kDocument.findFirst({ |
||||
|
where: { |
||||
|
id: kDocumentId, |
||||
|
partnership: { |
||||
|
members: { |
||||
|
some: { |
||||
|
entity: { |
||||
|
userId |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!kDoc) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Verify document exists
|
||||
|
const document = await this.prismaService.document.findUnique({ |
||||
|
where: { id: documentId } |
||||
|
}); |
||||
|
|
||||
|
if (!document) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.kDocument.update({ |
||||
|
data: { |
||||
|
documentFileId: documentId |
||||
|
}, |
||||
|
include: { |
||||
|
partnership: { |
||||
|
select: { name: true } |
||||
|
} |
||||
|
}, |
||||
|
where: { id: kDocumentId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private async computeAllocations( |
||||
|
partnershipId: string, |
||||
|
data: Record<string, number> |
||||
|
) { |
||||
|
const memberships = await this.prismaService.partnershipMembership.findMany( |
||||
|
{ |
||||
|
include: { |
||||
|
entity: { |
||||
|
select: { id: true, name: true } |
||||
|
} |
||||
|
}, |
||||
|
where: { |
||||
|
endDate: null, |
||||
|
partnershipId |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
return memberships.map((membership) => { |
||||
|
const ownershipPercent = new Big(membership.ownershipPercent.toString()); |
||||
|
const allocatedAmounts: Record<string, number> = {}; |
||||
|
|
||||
|
for (const field of K1_DATA_FIELDS) { |
||||
|
const value = data[field]; |
||||
|
|
||||
|
if (value !== undefined && value !== 0) { |
||||
|
allocatedAmounts[field] = ownershipPercent |
||||
|
.div(100) |
||||
|
.times(value) |
||||
|
.round(2) |
||||
|
.toNumber(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
allocatedAmounts, |
||||
|
entityId: membership.entity.id, |
||||
|
entityName: membership.entity.name, |
||||
|
ownershipPercent: ownershipPercent.toNumber() |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private normalizeK1Data( |
||||
|
data: Record<string, number> |
||||
|
): Record<string, number> { |
||||
|
const normalized: Record<string, number> = {}; |
||||
|
|
||||
|
for (const field of K1_DATA_FIELDS) { |
||||
|
normalized[field] = data[field] !== undefined ? Number(data[field]) : 0; |
||||
|
} |
||||
|
|
||||
|
return normalized; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,261 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { FamilyOfficeBenchmarkService } from '@ghostfolio/api/services/benchmark/family-office-benchmark.service'; |
||||
|
import { |
||||
|
CreatePartnershipAssetDto, |
||||
|
CreatePartnershipDto, |
||||
|
CreatePartnershipMembershipDto, |
||||
|
CreatePartnershipValuationDto, |
||||
|
UpdatePartnershipDto |
||||
|
} from '@ghostfolio/common/dtos'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Put, |
||||
|
Query, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { PartnershipService } from './partnership.service'; |
||||
|
|
||||
|
@Controller('partnership') |
||||
|
export class PartnershipController { |
||||
|
public constructor( |
||||
|
private readonly familyOfficeBenchmarkService: FamilyOfficeBenchmarkService, |
||||
|
private readonly partnershipService: PartnershipService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@HasPermission(permissions.createPartnership) |
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async createPartnership(@Body() data: CreatePartnershipDto) { |
||||
|
return this.partnershipService.createPartnership({ |
||||
|
name: data.name, |
||||
|
type: data.type as any, |
||||
|
inceptionDate: new Date(data.inceptionDate), |
||||
|
fiscalYearEnd: data.fiscalYearEnd ?? 12, |
||||
|
currency: data.currency, |
||||
|
user: { connect: { id: this.request.user.id } } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
@HasPermission(permissions.readPartnership) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getPartnerships() { |
||||
|
return this.partnershipService.getPartnerships({ |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get(':partnershipId') |
||||
|
@HasPermission(permissions.readPartnership) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getPartnershipById( |
||||
|
@Param('partnershipId') partnershipId: string |
||||
|
) { |
||||
|
return this.partnershipService.getPartnershipById({ |
||||
|
partnershipId, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updatePartnership) |
||||
|
@Put(':partnershipId') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async updatePartnership( |
||||
|
@Param('partnershipId') partnershipId: string, |
||||
|
@Body() data: UpdatePartnershipDto |
||||
|
) { |
||||
|
return this.partnershipService.updatePartnership({ |
||||
|
partnershipId, |
||||
|
userId: this.request.user.id, |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Delete(':partnershipId') |
||||
|
@HasPermission(permissions.deletePartnership) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async deletePartnership( |
||||
|
@Param('partnershipId') partnershipId: string |
||||
|
) { |
||||
|
return this.partnershipService.deletePartnership({ |
||||
|
partnershipId, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updatePartnership) |
||||
|
@Post(':partnershipId/member') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async addMember( |
||||
|
@Param('partnershipId') partnershipId: string, |
||||
|
@Body() data: CreatePartnershipMembershipDto |
||||
|
) { |
||||
|
return this.partnershipService.addMember({ |
||||
|
partnershipId, |
||||
|
userId: this.request.user.id, |
||||
|
data: { |
||||
|
entityId: data.entityId, |
||||
|
ownershipPercent: data.ownershipPercent, |
||||
|
capitalCommitment: data.capitalCommitment, |
||||
|
capitalContributed: data.capitalContributed, |
||||
|
classType: data.classType, |
||||
|
effectiveDate: data.effectiveDate |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updatePartnership) |
||||
|
@Put(':partnershipId/member/:membershipId') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async updateMember( |
||||
|
@Param('partnershipId') partnershipId: string, |
||||
|
@Param('membershipId') membershipId: string, |
||||
|
@Body() data: any |
||||
|
) { |
||||
|
return this.partnershipService.updateMember({ |
||||
|
partnershipId, |
||||
|
membershipId, |
||||
|
userId: this.request.user.id, |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updatePartnership) |
||||
|
@Post(':partnershipId/valuation') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async recordValuation( |
||||
|
@Param('partnershipId') partnershipId: string, |
||||
|
@Body() data: CreatePartnershipValuationDto |
||||
|
) { |
||||
|
return this.partnershipService.recordValuation({ |
||||
|
partnershipId, |
||||
|
userId: this.request.user.id, |
||||
|
data: { |
||||
|
date: data.date, |
||||
|
nav: data.nav, |
||||
|
source: data.source, |
||||
|
notes: data.notes |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get(':partnershipId/valuations') |
||||
|
@HasPermission(permissions.readPartnership) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getValuations( |
||||
|
@Param('partnershipId') partnershipId: string, |
||||
|
@Query('startDate') startDate?: string, |
||||
|
@Query('endDate') endDate?: string |
||||
|
) { |
||||
|
return this.partnershipService.getValuations({ |
||||
|
partnershipId, |
||||
|
userId: this.request.user.id, |
||||
|
startDate, |
||||
|
endDate |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updatePartnership) |
||||
|
@Post(':partnershipId/asset') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async addAsset( |
||||
|
@Param('partnershipId') partnershipId: string, |
||||
|
@Body() data: CreatePartnershipAssetDto |
||||
|
) { |
||||
|
return this.partnershipService.addAsset({ |
||||
|
partnershipId, |
||||
|
userId: this.request.user.id, |
||||
|
data: { |
||||
|
name: data.name, |
||||
|
assetType: data.assetType, |
||||
|
description: data.description, |
||||
|
acquisitionDate: data.acquisitionDate, |
||||
|
acquisitionCost: data.acquisitionCost, |
||||
|
currentValue: data.currentValue, |
||||
|
currency: data.currency, |
||||
|
metadata: data.metadata |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updatePartnership) |
||||
|
@Post(':partnershipId/asset/:assetId/valuation') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async addAssetValuation( |
||||
|
@Param('partnershipId') partnershipId: string, |
||||
|
@Param('assetId') assetId: string, |
||||
|
@Body() |
||||
|
data: { date: string; value: number; source: string; notes?: string } |
||||
|
) { |
||||
|
return this.partnershipService.addAssetValuation({ |
||||
|
partnershipId, |
||||
|
assetId, |
||||
|
userId: this.request.user.id, |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get(':partnershipId/performance') |
||||
|
@HasPermission(permissions.readPartnership) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getPerformance( |
||||
|
@Param('partnershipId') partnershipId: string, |
||||
|
@Query('benchmarks') benchmarks?: string, |
||||
|
@Query('endDate') endDate?: string, |
||||
|
@Query('periodicity') periodicity?: string, |
||||
|
@Query('startDate') startDate?: string |
||||
|
) { |
||||
|
const result = await this.partnershipService.getPerformance({ |
||||
|
endDate, |
||||
|
partnershipId, |
||||
|
periodicity: periodicity as any, |
||||
|
startDate, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
// If benchmarks requested, compute comparisons
|
||||
|
if (benchmarks) { |
||||
|
const benchmarkIds = benchmarks.split(',').map((b) => b.trim()); |
||||
|
const overallReturn = |
||||
|
result.periods.length > 0 |
||||
|
? result.periods.reduce( |
||||
|
(acc, p) => (1 + acc) * (1 + p.returnPercent) - 1, |
||||
|
0 |
||||
|
) |
||||
|
: 0; |
||||
|
|
||||
|
const partnership = await this.partnershipService.getPartnershipById({ |
||||
|
partnershipId, |
||||
|
userId: this.request.user.id |
||||
|
}); |
||||
|
|
||||
|
const comparisons = |
||||
|
await this.familyOfficeBenchmarkService.computeBenchmarkComparisons({ |
||||
|
benchmarkIds, |
||||
|
endDate: endDate ? new Date(endDate) : new Date(), |
||||
|
partnershipReturn: overallReturn, |
||||
|
startDate: startDate |
||||
|
? new Date(startDate) |
||||
|
: new Date(partnership.inceptionDate) |
||||
|
}); |
||||
|
|
||||
|
result.benchmarkComparisons = comparisons; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
import { FamilyOfficeBenchmarkService } from '@ghostfolio/api/services/benchmark/family-office-benchmark.service'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { PartnershipController } from './partnership.controller'; |
||||
|
import { PartnershipService } from './partnership.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [PartnershipController], |
||||
|
exports: [PartnershipService], |
||||
|
imports: [PrismaModule], |
||||
|
providers: [FamilyOfficeBenchmarkService, PartnershipService] |
||||
|
}) |
||||
|
export class PartnershipModule {} |
||||
@ -0,0 +1,679 @@ |
|||||
|
import { FamilyOfficePerformanceCalculator } from '@ghostfolio/api/app/portfolio/calculator/family-office/performance-calculator'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { Prisma } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class PartnershipService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async createPartnership(data: Prisma.PartnershipCreateInput) { |
||||
|
return this.prismaService.partnership.create({ data }); |
||||
|
} |
||||
|
|
||||
|
public async getPartnerships({ userId }: { userId: string }) { |
||||
|
const partnerships = await this.prismaService.partnership.findMany({ |
||||
|
where: { userId }, |
||||
|
include: { |
||||
|
_count: { |
||||
|
select: { |
||||
|
members: true, |
||||
|
assets: true |
||||
|
} |
||||
|
}, |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
} |
||||
|
}, |
||||
|
orderBy: { name: 'asc' } |
||||
|
}); |
||||
|
|
||||
|
return partnerships.map((p) => ({ |
||||
|
id: p.id, |
||||
|
name: p.name, |
||||
|
type: p.type, |
||||
|
inceptionDate: p.inceptionDate.toISOString(), |
||||
|
currency: p.currency, |
||||
|
latestNav: p.valuations[0] ? Number(p.valuations[0].nav) : null, |
||||
|
latestNavDate: p.valuations[0] |
||||
|
? p.valuations[0].date.toISOString() |
||||
|
: null, |
||||
|
membersCount: p._count.members, |
||||
|
assetsCount: p._count.assets |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
public async getPartnershipById({ |
||||
|
partnershipId, |
||||
|
userId |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId }, |
||||
|
include: { |
||||
|
members: { |
||||
|
where: { endDate: null }, |
||||
|
include: { |
||||
|
entity: true |
||||
|
} |
||||
|
}, |
||||
|
assets: { |
||||
|
include: { |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
valuations: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1 |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const latestNav = partnership.valuations[0] |
||||
|
? Number(partnership.valuations[0].nav) |
||||
|
: 0; |
||||
|
|
||||
|
const members = partnership.members.map((m) => ({ |
||||
|
id: m.id, |
||||
|
entityId: m.entityId, |
||||
|
entityName: m.entity.name, |
||||
|
ownershipPercent: Number(m.ownershipPercent), |
||||
|
capitalCommitment: m.capitalCommitment |
||||
|
? Number(m.capitalCommitment) |
||||
|
: undefined, |
||||
|
capitalContributed: m.capitalContributed |
||||
|
? Number(m.capitalContributed) |
||||
|
: undefined, |
||||
|
classType: m.classType ?? undefined, |
||||
|
allocatedNav: latestNav * (Number(m.ownershipPercent) / 100), |
||||
|
effectiveDate: m.effectiveDate.toISOString() |
||||
|
})); |
||||
|
|
||||
|
const assets = partnership.assets.map((a) => ({ |
||||
|
id: a.id, |
||||
|
name: a.name, |
||||
|
assetType: a.assetType, |
||||
|
currentValue: a.valuations[0] |
||||
|
? Number(a.valuations[0].value) |
||||
|
: a.currentValue |
||||
|
? Number(a.currentValue) |
||||
|
: undefined, |
||||
|
acquisitionCost: a.acquisitionCost |
||||
|
? Number(a.acquisitionCost) |
||||
|
: undefined, |
||||
|
currency: a.currency |
||||
|
})); |
||||
|
|
||||
|
const totalCommitment = members.reduce( |
||||
|
(sum, m) => sum + (m.capitalCommitment ?? 0), |
||||
|
0 |
||||
|
); |
||||
|
const totalContributed = members.reduce( |
||||
|
(sum, m) => sum + (m.capitalContributed ?? 0), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
id: partnership.id, |
||||
|
name: partnership.name, |
||||
|
type: partnership.type, |
||||
|
inceptionDate: partnership.inceptionDate.toISOString(), |
||||
|
fiscalYearEnd: partnership.fiscalYearEnd, |
||||
|
currency: partnership.currency, |
||||
|
latestValuation: partnership.valuations[0] |
||||
|
? { |
||||
|
date: partnership.valuations[0].date.toISOString(), |
||||
|
nav: Number(partnership.valuations[0].nav), |
||||
|
source: partnership.valuations[0].source |
||||
|
} |
||||
|
: null, |
||||
|
members, |
||||
|
assets, |
||||
|
totalCommitment, |
||||
|
totalContributed |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async updatePartnership({ |
||||
|
partnershipId, |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
userId: string; |
||||
|
data: { name?: string; fiscalYearEnd?: number }; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.partnership.update({ |
||||
|
where: { id: partnershipId }, |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async deletePartnership({ |
||||
|
partnershipId, |
||||
|
userId |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
userId: string; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Check for active members before deletion
|
||||
|
const activeMembers = await this.prismaService.partnershipMembership.count({ |
||||
|
where: { partnershipId, endDate: null } |
||||
|
}); |
||||
|
|
||||
|
if (activeMembers > 0) { |
||||
|
throw new HttpException( |
||||
|
'Partnership has active members and cannot be deleted', |
||||
|
StatusCodes.CONFLICT |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.partnership.delete({ |
||||
|
where: { id: partnershipId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async addMember({ |
||||
|
partnershipId, |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
userId: string; |
||||
|
data: { |
||||
|
entityId: string; |
||||
|
ownershipPercent: number; |
||||
|
capitalCommitment?: number; |
||||
|
capitalContributed?: number; |
||||
|
classType?: string; |
||||
|
effectiveDate: string; |
||||
|
}; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Validate total membership <= 100%
|
||||
|
const existingMembers = |
||||
|
await this.prismaService.partnershipMembership.findMany({ |
||||
|
where: { partnershipId, endDate: null } |
||||
|
}); |
||||
|
|
||||
|
const totalPercent = existingMembers.reduce( |
||||
|
(sum, m) => sum + Number(m.ownershipPercent), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
if (totalPercent + data.ownershipPercent > 100) { |
||||
|
throw new HttpException( |
||||
|
'Total membership percentages would exceed 100%', |
||||
|
StatusCodes.BAD_REQUEST |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.partnershipMembership.create({ |
||||
|
data: { |
||||
|
entity: { connect: { id: data.entityId } }, |
||||
|
partnership: { connect: { id: partnershipId } }, |
||||
|
ownershipPercent: data.ownershipPercent, |
||||
|
capitalCommitment: data.capitalCommitment, |
||||
|
capitalContributed: data.capitalContributed, |
||||
|
classType: data.classType, |
||||
|
effectiveDate: new Date(data.effectiveDate) |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async updateMember({ |
||||
|
partnershipId, |
||||
|
membershipId, |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
membershipId: string; |
||||
|
userId: string; |
||||
|
data: { |
||||
|
ownershipPercent?: number; |
||||
|
capitalCommitment?: number; |
||||
|
capitalContributed?: number; |
||||
|
classType?: string; |
||||
|
}; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const membership = await this.prismaService.partnershipMembership.findFirst( |
||||
|
{ |
||||
|
where: { id: membershipId, partnershipId } |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
if (!membership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// If updating ownership percent, validate total <= 100%
|
||||
|
if (data.ownershipPercent !== undefined) { |
||||
|
const existingMembers = |
||||
|
await this.prismaService.partnershipMembership.findMany({ |
||||
|
where: { partnershipId, endDate: null, id: { not: membershipId } } |
||||
|
}); |
||||
|
|
||||
|
const totalPercent = existingMembers.reduce( |
||||
|
(sum, m) => sum + Number(m.ownershipPercent), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
if (totalPercent + data.ownershipPercent > 100) { |
||||
|
throw new HttpException( |
||||
|
'Total membership percentages would exceed 100%', |
||||
|
StatusCodes.BAD_REQUEST |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.partnershipMembership.update({ |
||||
|
where: { id: membershipId }, |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async recordValuation({ |
||||
|
partnershipId, |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
userId: string; |
||||
|
data: { |
||||
|
date: string; |
||||
|
nav: number; |
||||
|
source: string; |
||||
|
notes?: string; |
||||
|
}; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Check for duplicate valuation date
|
||||
|
const existing = await this.prismaService.partnershipValuation.findFirst({ |
||||
|
where: { partnershipId, date: new Date(data.date) } |
||||
|
}); |
||||
|
|
||||
|
if (existing) { |
||||
|
throw new HttpException( |
||||
|
'Valuation already exists for this date', |
||||
|
StatusCodes.CONFLICT |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.partnershipValuation.create({ |
||||
|
data: { |
||||
|
partnership: { connect: { id: partnershipId } }, |
||||
|
date: new Date(data.date), |
||||
|
nav: data.nav, |
||||
|
source: data.source as any, |
||||
|
notes: data.notes |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getValuations({ |
||||
|
partnershipId, |
||||
|
userId, |
||||
|
startDate, |
||||
|
endDate |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
userId: string; |
||||
|
startDate?: string; |
||||
|
endDate?: string; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const where: any = { partnershipId }; |
||||
|
|
||||
|
if (startDate || endDate) { |
||||
|
where.date = {}; |
||||
|
if (startDate) { |
||||
|
where.date.gte = new Date(startDate); |
||||
|
} |
||||
|
if (endDate) { |
||||
|
where.date.lte = new Date(endDate); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const valuations = await this.prismaService.partnershipValuation.findMany({ |
||||
|
where, |
||||
|
orderBy: { date: 'desc' } |
||||
|
}); |
||||
|
|
||||
|
return valuations.map((v) => ({ |
||||
|
id: v.id, |
||||
|
date: v.date.toISOString(), |
||||
|
nav: Number(v.nav), |
||||
|
source: v.source |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
public async addAsset({ |
||||
|
partnershipId, |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
userId: string; |
||||
|
data: { |
||||
|
name: string; |
||||
|
assetType: string; |
||||
|
description?: string; |
||||
|
acquisitionDate?: string; |
||||
|
acquisitionCost?: number; |
||||
|
currentValue?: number; |
||||
|
currency: string; |
||||
|
metadata?: any; |
||||
|
}; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.partnershipAsset.create({ |
||||
|
data: { |
||||
|
partnership: { connect: { id: partnershipId } }, |
||||
|
name: data.name, |
||||
|
assetType: data.assetType as any, |
||||
|
description: data.description, |
||||
|
acquisitionDate: data.acquisitionDate |
||||
|
? new Date(data.acquisitionDate) |
||||
|
: undefined, |
||||
|
acquisitionCost: data.acquisitionCost, |
||||
|
currentValue: data.currentValue, |
||||
|
currency: data.currency, |
||||
|
metadata: data.metadata ?? undefined |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async addAssetValuation({ |
||||
|
partnershipId, |
||||
|
assetId, |
||||
|
userId, |
||||
|
data |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
assetId: string; |
||||
|
userId: string; |
||||
|
data: { |
||||
|
date: string; |
||||
|
value: number; |
||||
|
source: string; |
||||
|
notes?: string; |
||||
|
}; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId } |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const asset = await this.prismaService.partnershipAsset.findFirst({ |
||||
|
where: { id: assetId, partnershipId } |
||||
|
}); |
||||
|
|
||||
|
if (!asset) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.prismaService.assetValuation.create({ |
||||
|
data: { |
||||
|
partnershipAsset: { connect: { id: assetId } }, |
||||
|
date: new Date(data.date), |
||||
|
value: data.value, |
||||
|
source: data.source as any, |
||||
|
notes: data.notes |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getPerformance({ |
||||
|
partnershipId, |
||||
|
userId, |
||||
|
startDate, |
||||
|
endDate |
||||
|
}: { |
||||
|
partnershipId: string; |
||||
|
userId: string; |
||||
|
periodicity?: string; |
||||
|
startDate?: string; |
||||
|
endDate?: string; |
||||
|
}) { |
||||
|
const partnership = await this.prismaService.partnership.findFirst({ |
||||
|
where: { id: partnershipId, userId }, |
||||
|
include: { |
||||
|
members: { |
||||
|
where: { endDate: null } |
||||
|
}, |
||||
|
valuations: { |
||||
|
orderBy: { date: 'asc' } |
||||
|
}, |
||||
|
distributions: true |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!partnership) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const valuations = partnership.valuations |
||||
|
.filter((v) => { |
||||
|
if (startDate && v.date < new Date(startDate)) return false; |
||||
|
if (endDate && v.date > new Date(endDate)) return false; |
||||
|
return true; |
||||
|
}) |
||||
|
.map((v) => ({ |
||||
|
date: v.date, |
||||
|
nav: Number(v.nav) |
||||
|
})); |
||||
|
|
||||
|
// Calculate total contributions from members
|
||||
|
const totalContributions = partnership.members.reduce( |
||||
|
(sum, m) => sum + Number(m.capitalContributed || 0), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
const totalDistributions = partnership.distributions.reduce( |
||||
|
(sum, d) => sum + Number(d.amount), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
const latestNav = |
||||
|
valuations.length > 0 ? valuations[valuations.length - 1].nav : 0; |
||||
|
|
||||
|
// Build cash flows for XIRR
|
||||
|
// Contributions are negative (outflows), distributions and final NAV are positive
|
||||
|
const cashFlows = [ |
||||
|
// Initial contribution at inception
|
||||
|
...(totalContributions > 0 |
||||
|
? [ |
||||
|
{ |
||||
|
amount: -totalContributions, |
||||
|
date: partnership.inceptionDate |
||||
|
} |
||||
|
] |
||||
|
: []), |
||||
|
// Distributions as inflows
|
||||
|
...partnership.distributions.map((d) => ({ |
||||
|
amount: Number(d.amount), |
||||
|
date: d.date |
||||
|
})), |
||||
|
// Final NAV as terminal value
|
||||
|
...(valuations.length > 0 |
||||
|
? [ |
||||
|
{ |
||||
|
amount: latestNav, |
||||
|
date: valuations[valuations.length - 1].date |
||||
|
} |
||||
|
] |
||||
|
: []) |
||||
|
]; |
||||
|
|
||||
|
// Compute XIRR
|
||||
|
const irr = FamilyOfficePerformanceCalculator.computeXIRR(cashFlows); |
||||
|
|
||||
|
// Compute multiples
|
||||
|
const tvpi = FamilyOfficePerformanceCalculator.computeTVPI( |
||||
|
totalDistributions, |
||||
|
latestNav, |
||||
|
totalContributions |
||||
|
); |
||||
|
|
||||
|
const dpi = FamilyOfficePerformanceCalculator.computeDPI( |
||||
|
totalDistributions, |
||||
|
totalContributions |
||||
|
); |
||||
|
|
||||
|
const rvpi = FamilyOfficePerformanceCalculator.computeRVPI( |
||||
|
latestNav, |
||||
|
totalContributions |
||||
|
); |
||||
|
|
||||
|
// Build Modified Dietz period returns
|
||||
|
const periodData = []; |
||||
|
|
||||
|
for (let i = 1; i < valuations.length; i++) { |
||||
|
const periodCashFlows = partnership.distributions |
||||
|
.filter( |
||||
|
(d) => |
||||
|
d.date >= valuations[i - 1].date && d.date <= valuations[i].date |
||||
|
) |
||||
|
.map((d) => ({ |
||||
|
amount: Number(d.amount), |
||||
|
date: d.date |
||||
|
})); |
||||
|
|
||||
|
periodData.push({ |
||||
|
cashFlows: periodCashFlows, |
||||
|
endDate: valuations[i].date, |
||||
|
endValue: valuations[i].nav, |
||||
|
startDate: valuations[i - 1].date, |
||||
|
startValue: valuations[i - 1].nav |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const periodicReturns = |
||||
|
FamilyOfficePerformanceCalculator.computeModifiedDietzReturns(periodData); |
||||
|
|
||||
|
return { |
||||
|
partnershipId: partnership.id, |
||||
|
partnershipName: partnership.name, |
||||
|
metrics: { |
||||
|
dpi, |
||||
|
irr: irr !== null ? Math.round(irr * 10000) / 10000 : null, |
||||
|
rvpi, |
||||
|
tvpi |
||||
|
}, |
||||
|
periods: periodicReturns.map((r) => ({ |
||||
|
contributions: 0, |
||||
|
distributions: 0, |
||||
|
endNav: r.endValue, |
||||
|
periodEnd: r.endDate, |
||||
|
periodStart: r.startDate, |
||||
|
returnPercent: r.return, |
||||
|
startNav: r.startValue |
||||
|
})), |
||||
|
benchmarkComparisons: [] |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,252 @@ |
|||||
|
import Big from 'big.js'; |
||||
|
|
||||
|
export interface CashFlow { |
||||
|
amount: number; |
||||
|
date: Date; |
||||
|
} |
||||
|
|
||||
|
export interface PerformanceMetrics { |
||||
|
dpi: number; |
||||
|
irr: number | null; |
||||
|
periodicReturns: PeriodReturn[]; |
||||
|
rvpi: number; |
||||
|
tvpi: number; |
||||
|
} |
||||
|
|
||||
|
export interface PeriodReturn { |
||||
|
endDate: string; |
||||
|
endValue: number; |
||||
|
return: number; |
||||
|
startDate: string; |
||||
|
startValue: number; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Family Office Performance Calculator |
||||
|
* |
||||
|
* Computes: |
||||
|
* - XIRR (IRR) using Newton-Raphson iteration with big.js |
||||
|
* - TVPI: (distributions + NAV) / contributed |
||||
|
* - DPI: distributions / contributed |
||||
|
* - RVPI: NAV / contributed |
||||
|
* - Modified Dietz periodic returns |
||||
|
*/ |
||||
|
export class FamilyOfficePerformanceCalculator { |
||||
|
/** |
||||
|
* Compute DPI (Distributions to Paid-In) |
||||
|
* DPI = total distributions / total contributions |
||||
|
*/ |
||||
|
public static computeDPI( |
||||
|
totalDistributions: number, |
||||
|
totalContributions: number |
||||
|
): number { |
||||
|
if (totalContributions === 0) { |
||||
|
return 0; |
||||
|
} |
||||
|
|
||||
|
return new Big(totalDistributions) |
||||
|
.div(totalContributions) |
||||
|
.round(4) |
||||
|
.toNumber(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Compute Modified Dietz returns for a set of periods. |
||||
|
* Each period: start value, end value, and cash flows within the period. |
||||
|
*/ |
||||
|
public static computeModifiedDietzReturns( |
||||
|
periods: { |
||||
|
cashFlows: CashFlow[]; |
||||
|
endDate: Date; |
||||
|
endValue: number; |
||||
|
startDate: Date; |
||||
|
startValue: number; |
||||
|
}[] |
||||
|
): PeriodReturn[] { |
||||
|
return periods.map((period) => { |
||||
|
const totalDays = this.daysBetween(period.startDate, period.endDate); |
||||
|
|
||||
|
if (totalDays === 0) { |
||||
|
return { |
||||
|
endDate: period.endDate.toISOString().split('T')[0], |
||||
|
endValue: period.endValue, |
||||
|
return: 0, |
||||
|
startDate: period.startDate.toISOString().split('T')[0], |
||||
|
startValue: period.startValue |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// Weighted cash flows
|
||||
|
let weightedFlows = new Big(0); |
||||
|
let totalFlows = new Big(0); |
||||
|
|
||||
|
for (const cf of period.cashFlows) { |
||||
|
const daysRemaining = this.daysBetween(cf.date, period.endDate); |
||||
|
const weight = new Big(daysRemaining).div(totalDays); |
||||
|
const amount = new Big(cf.amount); |
||||
|
weightedFlows = weightedFlows.plus(amount.times(weight)); |
||||
|
totalFlows = totalFlows.plus(amount); |
||||
|
} |
||||
|
|
||||
|
const startVal = new Big(period.startValue); |
||||
|
const endVal = new Big(period.endValue); |
||||
|
|
||||
|
const denominator = startVal.plus(weightedFlows); |
||||
|
|
||||
|
if (denominator.eq(0)) { |
||||
|
return { |
||||
|
endDate: period.endDate.toISOString().split('T')[0], |
||||
|
endValue: period.endValue, |
||||
|
return: 0, |
||||
|
startDate: period.startDate.toISOString().split('T')[0], |
||||
|
startValue: period.startValue |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const periodReturn = endVal |
||||
|
.minus(startVal) |
||||
|
.minus(totalFlows) |
||||
|
.div(denominator) |
||||
|
.round(6) |
||||
|
.toNumber(); |
||||
|
|
||||
|
return { |
||||
|
endDate: period.endDate.toISOString().split('T')[0], |
||||
|
endValue: period.endValue, |
||||
|
return: periodReturn, |
||||
|
startDate: period.startDate.toISOString().split('T')[0], |
||||
|
startValue: period.startValue |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Compute RVPI (Residual Value to Paid-In) |
||||
|
* RVPI = current NAV / total contributions |
||||
|
*/ |
||||
|
public static computeRVPI( |
||||
|
currentNAV: number, |
||||
|
totalContributions: number |
||||
|
): number { |
||||
|
if (totalContributions === 0) { |
||||
|
return 0; |
||||
|
} |
||||
|
|
||||
|
return new Big(currentNAV).div(totalContributions).round(4).toNumber(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Compute TVPI (Total Value to Paid-In) |
||||
|
* TVPI = (distributions + current NAV) / total contributions |
||||
|
*/ |
||||
|
public static computeTVPI( |
||||
|
totalDistributions: number, |
||||
|
currentNAV: number, |
||||
|
totalContributions: number |
||||
|
): number { |
||||
|
if (totalContributions === 0) { |
||||
|
return 0; |
||||
|
} |
||||
|
|
||||
|
return new Big(totalDistributions) |
||||
|
.plus(currentNAV) |
||||
|
.div(totalContributions) |
||||
|
.round(4) |
||||
|
.toNumber(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Compute XIRR using Newton-Raphson iteration. |
||||
|
* Cash flows are (date, amount) pairs where: |
||||
|
* - Negative amounts are contributions (outflows) |
||||
|
* - Positive amounts are distributions (inflows) |
||||
|
* The final NAV is added as a positive cash flow at the end date. |
||||
|
* |
||||
|
* Returns annualized IRR as a decimal (e.g. 0.12 = 12%), or null if |
||||
|
* the method does not converge. |
||||
|
*/ |
||||
|
public static computeXIRR(cashFlows: CashFlow[]): number | null { |
||||
|
if (cashFlows.length < 2) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const sortedFlows = [...cashFlows].sort( |
||||
|
(a, b) => a.date.getTime() - b.date.getTime() |
||||
|
); |
||||
|
|
||||
|
const firstDate = sortedFlows[0].date; |
||||
|
|
||||
|
// Convert to years from first date
|
||||
|
const flows = sortedFlows.map((cf) => ({ |
||||
|
amount: new Big(cf.amount), |
||||
|
years: new Big(this.daysBetween(firstDate, cf.date)).div(365.25) |
||||
|
})); |
||||
|
|
||||
|
// Newton-Raphson
|
||||
|
let rate = new Big(0.1); // initial guess 10%
|
||||
|
const maxIterations = 100; |
||||
|
const tolerance = new Big(1e-7); |
||||
|
|
||||
|
for (let i = 0; i < maxIterations; i++) { |
||||
|
let npv = new Big(0); |
||||
|
let dnpv = new Big(0); |
||||
|
|
||||
|
for (const flow of flows) { |
||||
|
const base = new Big(1).plus(rate); |
||||
|
|
||||
|
if (base.lte(0)) { |
||||
|
// Avoid invalid base
|
||||
|
rate = rate.plus(0.1); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const exponent = flow.years.toNumber(); |
||||
|
const discountFactor = Math.pow(base.toNumber(), -exponent); |
||||
|
const df = new Big(discountFactor); |
||||
|
|
||||
|
npv = npv.plus(flow.amount.times(df)); |
||||
|
dnpv = dnpv.minus( |
||||
|
flow.amount.times(new Big(exponent)).times(df).div(base) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (dnpv.abs().lt(tolerance)) { |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
const adjustment = npv.div(dnpv); |
||||
|
rate = rate.minus(adjustment); |
||||
|
|
||||
|
if (npv.abs().lt(tolerance)) { |
||||
|
return rate.round(6).toNumber(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Check final convergence
|
||||
|
let finalNpv = new Big(0); |
||||
|
|
||||
|
for (const flow of flows) { |
||||
|
const base = new Big(1).plus(rate); |
||||
|
|
||||
|
if (base.lte(0)) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const exponent = flow.years.toNumber(); |
||||
|
const discountFactor = Math.pow(base.toNumber(), -exponent); |
||||
|
finalNpv = finalNpv.plus(flow.amount.times(new Big(discountFactor))); |
||||
|
} |
||||
|
|
||||
|
if (finalNpv.abs().lt(new Big(0.01))) { |
||||
|
return rate.round(6).toNumber(); |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
private static daysBetween(start: Date, end: Date): number { |
||||
|
const msPerDay = 86400000; |
||||
|
|
||||
|
return Math.round((end.getTime() - start.getTime()) / msPerDay); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Res, |
||||
|
UploadedFile, |
||||
|
UseGuards, |
||||
|
UseInterceptors |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { FileInterceptor } from '@nestjs/platform-express'; |
||||
|
import { DocumentType } from '@prisma/client'; |
||||
|
import type { Response } from 'express'; |
||||
|
|
||||
|
import { UploadService } from './upload.service'; |
||||
|
|
||||
|
@Controller('upload') |
||||
|
export class UploadController { |
||||
|
public constructor( |
||||
|
private readonly uploadService: UploadService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@HasPermission(permissions.uploadDocument) |
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
@UseInterceptors(FileInterceptor('file')) |
||||
|
public async uploadDocument(@UploadedFile() file: any) { |
||||
|
const body = this.request.body as any; |
||||
|
|
||||
|
return this.uploadService.createDocument({ |
||||
|
file, |
||||
|
entityId: body.entityId, |
||||
|
name: body.name, |
||||
|
partnershipId: body.partnershipId, |
||||
|
taxYear: body.taxYear ? Number(body.taxYear) : undefined, |
||||
|
type: body.type as DocumentType |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Get(':id/download') |
||||
|
@HasPermission(permissions.readDocument) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async downloadDocument(@Param('id') id: string, @Res() res: Response) { |
||||
|
const { document, stream } = await this.uploadService.getDocumentStream(id); |
||||
|
|
||||
|
res.set({ |
||||
|
'Content-Disposition': `attachment; filename="${document.name}"`, |
||||
|
'Content-Type': document.mimeType || 'application/octet-stream' |
||||
|
}); |
||||
|
|
||||
|
if (document.fileSize) { |
||||
|
res.set('Content-Length', document.fileSize.toString()); |
||||
|
} |
||||
|
|
||||
|
stream.pipe(res); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
import { MulterModule } from '@nestjs/platform-express'; |
||||
|
import { diskStorage } from 'multer'; |
||||
|
import { existsSync, mkdirSync } from 'node:fs'; |
||||
|
import { join } from 'node:path'; |
||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||
|
|
||||
|
import { UploadController } from './upload.controller'; |
||||
|
import { UploadService } from './upload.service'; |
||||
|
|
||||
|
const uploadDir = process.env.UPLOAD_DIR || join(process.cwd(), 'uploads'); |
||||
|
|
||||
|
if (!existsSync(uploadDir)) { |
||||
|
mkdirSync(uploadDir, { recursive: true }); |
||||
|
} |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [UploadController], |
||||
|
exports: [UploadService], |
||||
|
imports: [ |
||||
|
MulterModule.register({ |
||||
|
limits: { |
||||
|
fileSize: parseInt(process.env.MAX_UPLOAD_SIZE || '10485760', 10) |
||||
|
}, |
||||
|
storage: diskStorage({ |
||||
|
destination: (_req, _file, cb) => { |
||||
|
const now = new Date(); |
||||
|
const yearDir = now.getFullYear().toString(); |
||||
|
const monthDir = (now.getMonth() + 1).toString().padStart(2, '0'); |
||||
|
const subDir = join(uploadDir, yearDir, monthDir); |
||||
|
|
||||
|
if (!existsSync(subDir)) { |
||||
|
mkdirSync(subDir, { recursive: true }); |
||||
|
} |
||||
|
|
||||
|
cb(null, subDir); |
||||
|
}, |
||||
|
filename: (_req, file, cb) => { |
||||
|
const ext = file.originalname.split('.').pop(); |
||||
|
cb(null, `${uuidv4()}.${ext}`); |
||||
|
} |
||||
|
}) |
||||
|
}), |
||||
|
PrismaModule |
||||
|
], |
||||
|
providers: [UploadService] |
||||
|
}) |
||||
|
export class UploadModule {} |
||||
@ -0,0 +1,101 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { DocumentType } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
import { createReadStream, existsSync } from 'node:fs'; |
||||
|
import { mkdir } from 'node:fs/promises'; |
||||
|
import { join } from 'node:path'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class UploadService { |
||||
|
private readonly uploadDir: string; |
||||
|
|
||||
|
public constructor(private readonly prismaService: PrismaService) { |
||||
|
this.uploadDir = process.env.UPLOAD_DIR || join(process.cwd(), 'uploads'); |
||||
|
} |
||||
|
|
||||
|
public async ensureUploadDir(): Promise<void> { |
||||
|
if (!existsSync(this.uploadDir)) { |
||||
|
await mkdir(this.uploadDir, { recursive: true }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public getUploadDir(): string { |
||||
|
return this.uploadDir; |
||||
|
} |
||||
|
|
||||
|
public async createDocument({ |
||||
|
entityId, |
||||
|
file, |
||||
|
name, |
||||
|
partnershipId, |
||||
|
taxYear, |
||||
|
type |
||||
|
}: { |
||||
|
entityId?: string; |
||||
|
file: any; |
||||
|
name?: string; |
||||
|
partnershipId?: string; |
||||
|
taxYear?: number; |
||||
|
type: DocumentType; |
||||
|
}) { |
||||
|
await this.ensureUploadDir(); |
||||
|
|
||||
|
const now = new Date(); |
||||
|
const yearDir = now.getFullYear().toString(); |
||||
|
const monthDir = (now.getMonth() + 1).toString().padStart(2, '0'); |
||||
|
const subDir = join(this.uploadDir, yearDir, monthDir); |
||||
|
|
||||
|
if (!existsSync(subDir)) { |
||||
|
await mkdir(subDir, { recursive: true }); |
||||
|
} |
||||
|
|
||||
|
const relativePath = `/${yearDir}/${monthDir}/${file.filename}`; |
||||
|
|
||||
|
return this.prismaService.document.create({ |
||||
|
data: { |
||||
|
entityId: entityId || undefined, |
||||
|
filePath: relativePath, |
||||
|
fileSize: file.size, |
||||
|
mimeType: file.mimetype, |
||||
|
name: name || file.originalname, |
||||
|
partnershipId: partnershipId || undefined, |
||||
|
taxYear: taxYear ? Number(taxYear) : undefined, |
||||
|
type |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getDocumentById(documentId: string) { |
||||
|
const document = await this.prismaService.document.findUnique({ |
||||
|
where: { id: documentId } |
||||
|
}); |
||||
|
|
||||
|
if (!document) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return document; |
||||
|
} |
||||
|
|
||||
|
public async getDocumentStream(documentId: string) { |
||||
|
const document = await this.getDocumentById(documentId); |
||||
|
const fullPath = join(this.uploadDir, document.filePath); |
||||
|
|
||||
|
if (!existsSync(fullPath)) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
document, |
||||
|
stream: createReadStream(fullPath) |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,120 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { Injectable, Logger } from '@nestjs/common'; |
||||
|
import Big from 'big.js'; |
||||
|
|
||||
|
interface BenchmarkConfig { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
symbol: string; |
||||
|
} |
||||
|
|
||||
|
const DEFAULT_BENCHMARKS: BenchmarkConfig[] = [ |
||||
|
{ id: 'SP500', name: 'S&P 500', symbol: 'SPY' }, |
||||
|
{ id: 'US_AGG_BOND', name: 'US Agg Bond', symbol: 'AGG' }, |
||||
|
{ id: 'REAL_ESTATE', name: 'Real Estate', symbol: 'VNQ' }, |
||||
|
{ id: 'CPI_PROXY', name: 'CPI Proxy (TIPS)', symbol: 'TIP' } |
||||
|
]; |
||||
|
|
||||
|
export interface BenchmarkComparison { |
||||
|
excessReturn?: number; |
||||
|
id: string; |
||||
|
name: string; |
||||
|
periodReturn: number; |
||||
|
ytdReturn?: number; |
||||
|
} |
||||
|
|
||||
|
@Injectable() |
||||
|
export class FamilyOfficeBenchmarkService { |
||||
|
private readonly logger = new Logger(FamilyOfficeBenchmarkService.name); |
||||
|
|
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public getAvailableBenchmarks(): BenchmarkConfig[] { |
||||
|
return DEFAULT_BENCHMARKS; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Compute benchmark-matched returns for given benchmark IDs and date range. |
||||
|
* Uses market data from the MarketData table if available. |
||||
|
*/ |
||||
|
public async computeBenchmarkComparisons({ |
||||
|
benchmarkIds, |
||||
|
endDate, |
||||
|
partnershipReturn, |
||||
|
startDate |
||||
|
}: { |
||||
|
benchmarkIds: string[]; |
||||
|
endDate: Date; |
||||
|
partnershipReturn: number; |
||||
|
startDate: Date; |
||||
|
}): Promise<BenchmarkComparison[]> { |
||||
|
const comparisons: BenchmarkComparison[] = []; |
||||
|
|
||||
|
for (const benchmarkId of benchmarkIds) { |
||||
|
const config = DEFAULT_BENCHMARKS.find((b) => b.id === benchmarkId); |
||||
|
|
||||
|
if (!config) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// Try to get market data for this symbol
|
||||
|
const marketData = await this.prismaService.marketData.findMany({ |
||||
|
orderBy: { date: 'asc' }, |
||||
|
where: { |
||||
|
date: { |
||||
|
gte: startDate, |
||||
|
lte: endDate |
||||
|
}, |
||||
|
symbol: config.symbol |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (marketData.length >= 2) { |
||||
|
const startValue = Number(marketData[0].marketPrice); |
||||
|
const endValue = Number( |
||||
|
marketData[marketData.length - 1].marketPrice |
||||
|
); |
||||
|
|
||||
|
const periodReturn = new Big(endValue) |
||||
|
.div(startValue) |
||||
|
.minus(1) |
||||
|
.round(6) |
||||
|
.toNumber(); |
||||
|
|
||||
|
const excessReturn = new Big(partnershipReturn) |
||||
|
.minus(periodReturn) |
||||
|
.round(6) |
||||
|
.toNumber(); |
||||
|
|
||||
|
comparisons.push({ |
||||
|
excessReturn, |
||||
|
id: config.id, |
||||
|
name: config.name, |
||||
|
periodReturn |
||||
|
}); |
||||
|
} else { |
||||
|
// No market data available — return placeholder
|
||||
|
comparisons.push({ |
||||
|
id: config.id, |
||||
|
name: config.name, |
||||
|
periodReturn: 0 |
||||
|
}); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
this.logger.warn( |
||||
|
`Failed to compute benchmark ${config.id}: ${error.message}` |
||||
|
); |
||||
|
|
||||
|
comparisons.push({ |
||||
|
id: config.id, |
||||
|
name: config.name, |
||||
|
periodReturn: 0 |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return comparisons; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,172 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
Inject, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
ReactiveFormsModule, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { |
||||
|
MAT_DIALOG_DATA, |
||||
|
MatDialogModule, |
||||
|
MatDialogRef |
||||
|
} from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
import { CreateDistributionDialogParams } from './interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-create-distribution-dialog', |
||||
|
template: ` |
||||
|
<form |
||||
|
class="d-flex flex-column h-100" |
||||
|
[formGroup]="distributionForm" |
||||
|
(keyup.enter)="distributionForm.valid && onSubmit()" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
<h1 i18n mat-dialog-title>Record Distribution</h1> |
||||
|
|
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Entity</mat-label> |
||||
|
<mat-select formControlName="entityId" required> |
||||
|
@for (entity of data.entities; track entity.id) { |
||||
|
<mat-option [value]="entity.id">{{ entity.name }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Partnership (Optional)</mat-label> |
||||
|
<mat-select formControlName="partnershipId"> |
||||
|
<mat-option [value]="null">None</mat-option> |
||||
|
@for (p of data.partnerships; track p.id) { |
||||
|
<mat-option [value]="p.id">{{ p.name }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Type</mat-label> |
||||
|
<mat-select formControlName="type" required> |
||||
|
<mat-option value="RETURN_OF_CAPITAL" |
||||
|
>Return of Capital</mat-option |
||||
|
> |
||||
|
<mat-option value="INCOME">Income</mat-option> |
||||
|
<mat-option value="CAPITAL_GAIN">Capital Gain</mat-option> |
||||
|
<mat-option value="DIVIDEND">Dividend</mat-option> |
||||
|
<mat-option value="INTEREST">Interest</mat-option> |
||||
|
<mat-option value="OTHER">Other</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Amount</mat-label> |
||||
|
<input formControlName="amount" matInput required type="number" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Currency</mat-label> |
||||
|
<input formControlName="currency" matInput required /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Date</mat-label> |
||||
|
<input formControlName="date" matInput required type="date" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Tax Withheld (Optional)</mat-label> |
||||
|
<input formControlName="taxWithheld" matInput type="number" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Notes (Optional)</mat-label> |
||||
|
<input formControlName="notes" matInput /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button type="button" (click)="onCancel()"> |
||||
|
Cancel |
||||
|
</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="!distributionForm.valid" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
` |
||||
|
}) |
||||
|
export class GfCreateDistributionDialogComponent implements OnInit { |
||||
|
public distributionForm: FormGroup; |
||||
|
|
||||
|
public constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateDistributionDialogParams, |
||||
|
public dialogRef: MatDialogRef<GfCreateDistributionDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.distributionForm = new FormGroup({ |
||||
|
entityId: new FormControl(null, [Validators.required]), |
||||
|
partnershipId: new FormControl(null), |
||||
|
type: new FormControl('INCOME', [Validators.required]), |
||||
|
amount: new FormControl(null, [Validators.required, Validators.min(0)]), |
||||
|
currency: new FormControl('USD', [Validators.required]), |
||||
|
date: new FormControl(new Date().toISOString().split('T')[0], [ |
||||
|
Validators.required |
||||
|
]), |
||||
|
taxWithheld: new FormControl(null), |
||||
|
notes: new FormControl('') |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
if (this.distributionForm.valid) { |
||||
|
this.dialogRef.close(this.distributionForm.value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
export interface CreateDistributionDialogParams { |
||||
|
entities: { id: string; name: string }[]; |
||||
|
partnerships: { id: string; name: string }[]; |
||||
|
} |
||||
@ -0,0 +1,196 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { User } from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { NotificationService } from '@ghostfolio/ui/notifications'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { FormsModule } from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatMenuModule } from '@angular/material/menu'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
import { MatSortModule } from '@angular/material/sort'; |
||||
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; |
||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { addOutline, ellipsisVerticalOutline } from 'ionicons/icons'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
||||
|
|
||||
|
import { GfCreateDistributionDialogComponent } from './create-distribution-dialog/create-distribution-dialog.component'; |
||||
|
import { CreateDistributionDialogParams } from './create-distribution-dialog/interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'has-fab page' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
MatButtonModule, |
||||
|
MatFormFieldModule, |
||||
|
MatMenuModule, |
||||
|
MatSelectModule, |
||||
|
MatSortModule, |
||||
|
MatTableModule, |
||||
|
NgxSkeletonLoaderModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
selector: 'gf-distributions-page', |
||||
|
styleUrls: ['./distributions-page.scss'], |
||||
|
templateUrl: './distributions-page.html' |
||||
|
}) |
||||
|
export class GfDistributionsPageComponent implements OnInit { |
||||
|
public dataSource = new MatTableDataSource<any>(); |
||||
|
public deviceType: string; |
||||
|
public displayedColumns = [ |
||||
|
'date', |
||||
|
'entityName', |
||||
|
'partnershipName', |
||||
|
'type', |
||||
|
'amount', |
||||
|
'netAmount', |
||||
|
'actions' |
||||
|
]; |
||||
|
public entities: any[] = []; |
||||
|
public filterType: string | undefined; |
||||
|
public groupBy: string | undefined; |
||||
|
public hasPermissionToCreate = false; |
||||
|
public hasPermissionToDelete = false; |
||||
|
public isLoading = true; |
||||
|
public partnerships: any[] = []; |
||||
|
public summary: any; |
||||
|
public user: User; |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private deviceService: DeviceDetectorService, |
||||
|
private dialog: MatDialog, |
||||
|
private familyOfficeDataService: FamilyOfficeDataService, |
||||
|
private notificationService: NotificationService, |
||||
|
private route: ActivatedRoute, |
||||
|
private router: Router, |
||||
|
private userService: UserService |
||||
|
) { |
||||
|
addIcons({ addOutline, ellipsisVerticalOutline }); |
||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
||||
|
|
||||
|
this.route.queryParams |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((params) => { |
||||
|
if (params['createDialog']) { |
||||
|
this.openCreateDistributionDialog(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
this.hasPermissionToCreate = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.createDistribution |
||||
|
); |
||||
|
this.hasPermissionToDelete = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.deleteDistribution |
||||
|
); |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.fetchDistributions(); |
||||
|
this.fetchEntitiesAndPartnerships(); |
||||
|
} |
||||
|
|
||||
|
public fetchDistributions() { |
||||
|
this.isLoading = true; |
||||
|
|
||||
|
this.familyOfficeDataService |
||||
|
.fetchDistributions({ |
||||
|
type: this.filterType, |
||||
|
groupBy: this.groupBy |
||||
|
}) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result: any) => { |
||||
|
this.dataSource.data = result.distributions ?? []; |
||||
|
this.summary = result.summary; |
||||
|
this.isLoading = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onDeleteDistribution(distributionId: string) { |
||||
|
this.notificationService.confirm({ |
||||
|
confirmFn: () => { |
||||
|
this.familyOfficeDataService |
||||
|
.deleteDistribution(distributionId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.fetchDistributions(); |
||||
|
}); |
||||
|
}, |
||||
|
confirmType: undefined, |
||||
|
message: $localize`Do you really want to delete this distribution?`, |
||||
|
title: $localize`Delete Distribution` |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchEntitiesAndPartnerships() { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchEntities() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((entities) => { |
||||
|
this.entities = entities; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
|
||||
|
this.familyOfficeDataService |
||||
|
.fetchPartnerships() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((partnerships) => { |
||||
|
this.partnerships = partnerships; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openCreateDistributionDialog() { |
||||
|
const dialogRef = this.dialog.open(GfCreateDistributionDialogComponent, { |
||||
|
data: { |
||||
|
entities: this.entities, |
||||
|
partnerships: this.partnerships |
||||
|
} as CreateDistributionDialogParams, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
this.router.navigate([], { queryParams: {} }); |
||||
|
|
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.createDistribution(result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.fetchDistributions(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Distributions</h1> |
||||
|
|
||||
|
<div class="align-items-center d-flex flex-wrap gap-3 mb-3"> |
||||
|
<mat-form-field appearance="outline" class="filter-field"> |
||||
|
<mat-label i18n>Type</mat-label> |
||||
|
<mat-select |
||||
|
[(ngModel)]="filterType" |
||||
|
(selectionChange)="fetchDistributions()" |
||||
|
> |
||||
|
<mat-option [value]="undefined">All</mat-option> |
||||
|
<mat-option value="RETURN_OF_CAPITAL">Return of Capital</mat-option> |
||||
|
<mat-option value="INCOME">Income</mat-option> |
||||
|
<mat-option value="CAPITAL_GAIN">Capital Gain</mat-option> |
||||
|
<mat-option value="DIVIDEND">Dividend</mat-option> |
||||
|
<mat-option value="INTEREST">Interest</mat-option> |
||||
|
<mat-option value="OTHER">Other</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" class="filter-field"> |
||||
|
<mat-label i18n>Group By</mat-label> |
||||
|
<mat-select |
||||
|
[(ngModel)]="groupBy" |
||||
|
(selectionChange)="fetchDistributions()" |
||||
|
> |
||||
|
<mat-option [value]="undefined">None</mat-option> |
||||
|
<mat-option value="MONTHLY">Monthly</mat-option> |
||||
|
<mat-option value="QUARTERLY">Quarterly</mat-option> |
||||
|
<mat-option value="YEARLY">Yearly</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
@if (isLoading) { |
||||
|
<ngx-skeleton-loader |
||||
|
animation="pulse" |
||||
|
[theme]="{ height: '3rem', width: '100%' }" |
||||
|
/> |
||||
|
} @else { |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
matSort |
||||
|
[dataSource]="dataSource" |
||||
|
> |
||||
|
<ng-container matColumnDef="date"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Date</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.date | date }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="entityName"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header> |
||||
|
Entity |
||||
|
</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.entityName }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="partnershipName"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell" |
||||
|
i18n |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Partnership |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="d-none d-lg-table-cell" mat-cell> |
||||
|
{{ row.partnershipName ?? '—' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="type"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Type</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.type }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="amount"> |
||||
|
<th *matHeaderCellDef class="text-right" i18n mat-header-cell> |
||||
|
Amount |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
{{ row.amount | currency: row.currency : 'symbol' : '1.2-2' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="netAmount"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
i18n |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Net |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ row.netAmount | currency: row.currency : 'symbol' : '1.2-2' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="actions"> |
||||
|
<th *matHeaderCellDef class="text-right" mat-header-cell></th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
@if (hasPermissionToDelete) { |
||||
|
<button mat-icon-button [matMenuTriggerFor]="actionMenu"> |
||||
|
<ion-icon name="ellipsis-vertical-outline" /> |
||||
|
</button> |
||||
|
<mat-menu #actionMenu="matMenu"> |
||||
|
<button mat-menu-item (click)="onDeleteDistribution(row.id)"> |
||||
|
Delete |
||||
|
</button> |
||||
|
</mat-menu> |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> |
||||
|
</table> |
||||
|
|
||||
|
@if (dataSource.data.length === 0) { |
||||
|
<p class="p-3 text-center text-muted" i18n>No distributions found.</p> |
||||
|
} |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
@if (hasPermissionToCreate) { |
||||
|
<div class="fab-container"> |
||||
|
<a mat-fab [queryParams]="{ createDialog: true }" [routerLink]="[]"> |
||||
|
<ion-icon name="add-outline" size="large" /> |
||||
|
</a> |
||||
|
</div> |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { GfDistributionsPageComponent } from './distributions-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: GfDistributionsPageComponent, |
||||
|
path: '', |
||||
|
title: 'Distributions' |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,7 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
.filter-field { |
||||
|
min-width: 10rem; |
||||
|
} |
||||
@ -0,0 +1,84 @@ |
|||||
|
import { CreateEntityDto } from '@ghostfolio/common/dtos'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { Component } from '@angular/core'; |
||||
|
import { FormsModule } from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
@Component({ |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule |
||||
|
], |
||||
|
selector: 'gf-create-entity-dialog', |
||||
|
standalone: true, |
||||
|
template: ` |
||||
|
<h2 mat-dialog-title>Create Entity</h2> |
||||
|
<mat-dialog-content> |
||||
|
<mat-form-field appearance="outline" style="width: 100%"> |
||||
|
<mat-label>Name</mat-label> |
||||
|
<input matInput required [(ngModel)]="name" /> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" style="width: 100%"> |
||||
|
<mat-label>Type</mat-label> |
||||
|
<mat-select required [(ngModel)]="type"> |
||||
|
<mat-option value="INDIVIDUAL">Individual</mat-option> |
||||
|
<mat-option value="TRUST">Trust</mat-option> |
||||
|
<mat-option value="LLC">LLC</mat-option> |
||||
|
<mat-option value="LP">LP</mat-option> |
||||
|
<mat-option value="CORPORATION">Corporation</mat-option> |
||||
|
<mat-option value="FOUNDATION">Foundation</mat-option> |
||||
|
<mat-option value="ESTATE">Estate</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" style="width: 100%"> |
||||
|
<mat-label>Tax ID (optional)</mat-label> |
||||
|
<input matInput [(ngModel)]="taxId" /> |
||||
|
</mat-form-field> |
||||
|
</mat-dialog-content> |
||||
|
<mat-dialog-actions align="end"> |
||||
|
<button mat-button mat-dialog-close>Cancel</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
[disabled]="!name || !type" |
||||
|
(click)="onSave()" |
||||
|
> |
||||
|
Create |
||||
|
</button> |
||||
|
</mat-dialog-actions> |
||||
|
` |
||||
|
}) |
||||
|
export class GfCreateEntityDialogComponent { |
||||
|
public name = ''; |
||||
|
public taxId = ''; |
||||
|
public type = ''; |
||||
|
|
||||
|
public constructor( |
||||
|
private dialogRef: MatDialogRef<GfCreateEntityDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public onSave() { |
||||
|
const dto: CreateEntityDto = { |
||||
|
name: this.name, |
||||
|
type: this.type as any |
||||
|
}; |
||||
|
|
||||
|
if (this.taxId) { |
||||
|
dto.taxId = this.taxId; |
||||
|
} |
||||
|
|
||||
|
this.dialogRef.close(dto); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,125 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
Inject, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
ReactiveFormsModule, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { |
||||
|
MAT_DIALOG_DATA, |
||||
|
MatDialogModule, |
||||
|
MatDialogRef |
||||
|
} from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
import { CreateOrUpdateEntityDialogParams } from './interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-create-or-update-entity-dialog', |
||||
|
template: ` |
||||
|
<form |
||||
|
class="d-flex flex-column h-100" |
||||
|
[formGroup]="entityForm" |
||||
|
(keyup.enter)="entityForm.valid && onSubmit()" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
@if (data.entity.id) { |
||||
|
<h1 i18n mat-dialog-title>Update Entity</h1> |
||||
|
} @else { |
||||
|
<h1 i18n mat-dialog-title>Add Entity</h1> |
||||
|
} |
||||
|
|
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Name</mat-label> |
||||
|
<input formControlName="name" matInput required /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Type</mat-label> |
||||
|
<mat-select formControlName="type" required> |
||||
|
<mat-option value="INDIVIDUAL">Individual</mat-option> |
||||
|
<mat-option value="TRUST">Trust</mat-option> |
||||
|
<mat-option value="LLC">LLC</mat-option> |
||||
|
<mat-option value="LP">LP</mat-option> |
||||
|
<mat-option value="CORPORATION">Corporation</mat-option> |
||||
|
<mat-option value="FOUNDATION">Foundation</mat-option> |
||||
|
<mat-option value="ESTATE">Estate</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Tax ID</mat-label> |
||||
|
<input formControlName="taxId" matInput placeholder="Optional" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button type="button" (click)="onCancel()"> |
||||
|
{{ entityForm.dirty ? 'Cancel' : 'Close' }} |
||||
|
</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="!(entityForm.dirty && entityForm.valid)" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
` |
||||
|
}) |
||||
|
export class GfCreateOrUpdateEntityDialogComponent implements OnInit { |
||||
|
public entityForm: FormGroup; |
||||
|
|
||||
|
public constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) |
||||
|
public data: CreateOrUpdateEntityDialogParams, |
||||
|
public dialogRef: MatDialogRef<GfCreateOrUpdateEntityDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.entityForm = new FormGroup({ |
||||
|
name: new FormControl(this.data.entity.name, [Validators.required]), |
||||
|
type: new FormControl(this.data.entity.type, [Validators.required]), |
||||
|
taxId: new FormControl(this.data.entity.taxId) |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
if (this.entityForm.valid) { |
||||
|
this.dialogRef.close(this.entityForm.value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
export interface CreateOrUpdateEntityDialogParams { |
||||
|
entity: { |
||||
|
id: string | null; |
||||
|
name: string; |
||||
|
type: string; |
||||
|
taxId: string; |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,240 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { User } from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { NotificationService } from '@ghostfolio/ui/notifications'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit, |
||||
|
ViewChild |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { MatMenuModule } from '@angular/material/menu'; |
||||
|
import { MatSort, MatSortModule } from '@angular/material/sort'; |
||||
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; |
||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { addOutline, ellipsisVerticalOutline } from 'ionicons/icons'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
||||
|
|
||||
|
import { GfCreateOrUpdateEntityDialogComponent } from './create-or-update-entity-dialog/create-or-update-entity-dialog.component'; |
||||
|
import { CreateOrUpdateEntityDialogParams } from './create-or-update-entity-dialog/interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
host: { class: 'has-fab page' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatMenuModule, |
||||
|
MatSortModule, |
||||
|
MatTableModule, |
||||
|
NgxSkeletonLoaderModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
selector: 'gf-entities-page', |
||||
|
styleUrls: ['./entities-page.scss'], |
||||
|
templateUrl: './entities-page.html' |
||||
|
}) |
||||
|
export class GfEntitiesPageComponent implements OnInit { |
||||
|
public dataSource = new MatTableDataSource<any>([]); |
||||
|
public deviceType: string; |
||||
|
public displayedColumns = [ |
||||
|
'name', |
||||
|
'type', |
||||
|
'taxId', |
||||
|
'ownershipsCount', |
||||
|
'membershipsCount', |
||||
|
'actions' |
||||
|
]; |
||||
|
public entities: any[]; |
||||
|
public hasPermissionToCreate = false; |
||||
|
public hasPermissionToUpdate = false; |
||||
|
public hasPermissionToDelete = false; |
||||
|
public isLoading = true; |
||||
|
public showActions = false; |
||||
|
public user: User; |
||||
|
|
||||
|
@ViewChild(MatSort) sort: MatSort; |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private deviceService: DeviceDetectorService, |
||||
|
private dialog: MatDialog, |
||||
|
private familyOfficeDataService: FamilyOfficeDataService, |
||||
|
private notificationService: NotificationService, |
||||
|
private route: ActivatedRoute, |
||||
|
private router: Router, |
||||
|
private userService: UserService |
||||
|
) { |
||||
|
addIcons({ addOutline, ellipsisVerticalOutline }); |
||||
|
|
||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
||||
|
|
||||
|
this.route.queryParams |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((params) => { |
||||
|
if (params['createDialog']) { |
||||
|
this.openCreateEntityDialog(); |
||||
|
} else if (params['editDialog']) { |
||||
|
const entity = this.entities?.find( |
||||
|
(e) => e.id === params['entityId'] |
||||
|
); |
||||
|
if (entity) { |
||||
|
this.openUpdateEntityDialog(entity); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
this.hasPermissionToCreate = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.createEntity |
||||
|
); |
||||
|
this.hasPermissionToUpdate = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.updateEntity |
||||
|
); |
||||
|
this.hasPermissionToDelete = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.deleteEntity |
||||
|
); |
||||
|
this.showActions = |
||||
|
this.hasPermissionToUpdate || this.hasPermissionToDelete; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.fetchEntities(); |
||||
|
} |
||||
|
|
||||
|
public fetchEntities() { |
||||
|
this.isLoading = true; |
||||
|
this.familyOfficeDataService |
||||
|
.fetchEntities() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((entities) => { |
||||
|
this.entities = entities; |
||||
|
this.dataSource.data = entities; |
||||
|
this.dataSource.sort = this.sort; |
||||
|
this.isLoading = false; |
||||
|
|
||||
|
if (this.entities?.length <= 0) { |
||||
|
this.router.navigate([], { |
||||
|
queryParams: { createDialog: true } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onDeleteEntity(entityId: string) { |
||||
|
this.notificationService.confirm({ |
||||
|
confirmFn: () => { |
||||
|
this.familyOfficeDataService |
||||
|
.deleteEntity(entityId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.reset(); |
||||
|
this.fetchEntities(); |
||||
|
}); |
||||
|
}, |
||||
|
confirmType: undefined, |
||||
|
message: $localize`Do you really want to delete this entity?`, |
||||
|
title: $localize`Delete Entity` |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onEntityClicked(entity: any) { |
||||
|
this.router.navigate(['/entities', entity.id]); |
||||
|
} |
||||
|
|
||||
|
public onUpdateEntity(entity: any) { |
||||
|
this.router.navigate([], { |
||||
|
queryParams: { entityId: entity.id, editDialog: true } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openCreateEntityDialog() { |
||||
|
const dialogRef = this.dialog.open(GfCreateOrUpdateEntityDialogComponent, { |
||||
|
data: { |
||||
|
entity: { |
||||
|
id: null, |
||||
|
name: '', |
||||
|
type: 'INDIVIDUAL', |
||||
|
taxId: '' |
||||
|
} |
||||
|
} as CreateOrUpdateEntityDialogParams, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
this.router.navigate(['.'], { relativeTo: this.route }); |
||||
|
|
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.createEntity(result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.reset(); |
||||
|
this.fetchEntities(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openUpdateEntityDialog(entity: any) { |
||||
|
const dialogRef = this.dialog.open(GfCreateOrUpdateEntityDialogComponent, { |
||||
|
data: { |
||||
|
entity: { |
||||
|
id: entity.id, |
||||
|
name: entity.name, |
||||
|
type: entity.type, |
||||
|
taxId: entity.taxId ?? '' |
||||
|
} |
||||
|
} as CreateOrUpdateEntityDialogParams, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
this.router.navigate(['.'], { relativeTo: this.route }); |
||||
|
|
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.updateEntity(entity.id, result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.reset(); |
||||
|
this.fetchEntities(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private reset() { |
||||
|
this.entities = undefined; |
||||
|
this.dataSource.data = []; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,126 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Entities</h1> |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
matSort |
||||
|
matSortActive="name" |
||||
|
matSortDirection="asc" |
||||
|
[dataSource]="dataSource" |
||||
|
> |
||||
|
<ng-container matColumnDef="name"> |
||||
|
<th *matHeaderCellDef mat-header-cell mat-sort-header>Name</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.name }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="type"> |
||||
|
<th *matHeaderCellDef mat-header-cell mat-sort-header>Type</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.type }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="taxId"> |
||||
|
<th *matHeaderCellDef class="d-none d-lg-table-cell" mat-header-cell> |
||||
|
Tax ID |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="d-none d-lg-table-cell" mat-cell> |
||||
|
{{ row.taxId ?? '—' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="ownershipsCount"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Accounts |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ row.ownershipsCount ?? 0 }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="membershipsCount"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Partnerships |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ row.membershipsCount ?? 0 }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="actions" stickyEnd> |
||||
|
<th *matHeaderCellDef mat-header-cell></th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
@if (showActions) { |
||||
|
<button |
||||
|
mat-icon-button |
||||
|
[matMenuTriggerFor]="entityMenu" |
||||
|
(click)="$event.stopPropagation()" |
||||
|
> |
||||
|
<ion-icon name="ellipsis-vertical-outline" /> |
||||
|
</button> |
||||
|
<mat-menu #entityMenu="matMenu"> |
||||
|
<button mat-menu-item (click)="onUpdateEntity(row)"> |
||||
|
Edit |
||||
|
</button> |
||||
|
<button mat-menu-item (click)="onDeleteEntity(row.id)"> |
||||
|
Delete |
||||
|
</button> |
||||
|
</mat-menu> |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
||||
|
<tr |
||||
|
*matRowDef="let row; columns: displayedColumns" |
||||
|
class="cursor-pointer" |
||||
|
mat-row |
||||
|
(click)="onEntityClicked(row)" |
||||
|
></tr> |
||||
|
</table> |
||||
|
|
||||
|
@if (isLoading) { |
||||
|
<ngx-skeleton-loader |
||||
|
animation="pulse" |
||||
|
[theme]="{ height: '3rem', width: '100%' }" |
||||
|
/> |
||||
|
} |
||||
|
|
||||
|
@if (!isLoading && entities?.length === 0) { |
||||
|
<div class="p-3 text-center" style="opacity: 0.6"> |
||||
|
<p i18n>No entities yet. Create your first entity to get started.</p> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
@if (hasPermissionToCreate) { |
||||
|
<div class="fab-container"> |
||||
|
<a |
||||
|
class="align-items-center d-flex justify-content-center" |
||||
|
color="primary" |
||||
|
mat-fab |
||||
|
[queryParams]="{ createDialog: true }" |
||||
|
[routerLink]="[]" |
||||
|
> |
||||
|
<ion-icon name="add-outline" size="large" /> |
||||
|
</a> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { GfEntitiesPageComponent } from './entities-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: GfEntitiesPageComponent, |
||||
|
path: '', |
||||
|
title: 'Entities' |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,3 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
@ -0,0 +1,169 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
Inject, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
ReactiveFormsModule, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { |
||||
|
MAT_DIALOG_DATA, |
||||
|
MatDialogModule, |
||||
|
MatDialogRef |
||||
|
} from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
import { AddOwnershipDialogParams } from './interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-add-ownership-dialog', |
||||
|
template: ` |
||||
|
<form |
||||
|
class="d-flex flex-column h-100" |
||||
|
[formGroup]="ownershipForm" |
||||
|
(keyup.enter)="ownershipForm.valid && onSubmit()" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
<h1 i18n mat-dialog-title>Add Account Ownership</h1> |
||||
|
|
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Account</mat-label> |
||||
|
<mat-select formControlName="accountId" required> |
||||
|
@for (account of data.accounts; track account.id) { |
||||
|
<mat-option [value]="account.id">{{ account.name }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Ownership %</mat-label> |
||||
|
<input |
||||
|
formControlName="ownershipPercent" |
||||
|
matInput |
||||
|
max="100" |
||||
|
min="0" |
||||
|
required |
||||
|
type="number" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Effective Date</mat-label> |
||||
|
<input |
||||
|
formControlName="effectiveDate" |
||||
|
matInput |
||||
|
required |
||||
|
type="date" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Acquisition Date</mat-label> |
||||
|
<input |
||||
|
formControlName="acquisitionDate" |
||||
|
matInput |
||||
|
placeholder="Optional" |
||||
|
type="date" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Cost Basis</mat-label> |
||||
|
<input |
||||
|
formControlName="costBasis" |
||||
|
matInput |
||||
|
placeholder="Optional" |
||||
|
type="number" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button type="button" (click)="onCancel()"> |
||||
|
Cancel |
||||
|
</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="!ownershipForm.valid" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
` |
||||
|
}) |
||||
|
export class GfAddOwnershipDialogComponent implements OnInit { |
||||
|
public ownershipForm: FormGroup; |
||||
|
|
||||
|
public constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) public data: AddOwnershipDialogParams, |
||||
|
public dialogRef: MatDialogRef<GfAddOwnershipDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.ownershipForm = new FormGroup({ |
||||
|
accountId: new FormControl('', [Validators.required]), |
||||
|
ownershipPercent: new FormControl(100, [ |
||||
|
Validators.required, |
||||
|
Validators.min(0), |
||||
|
Validators.max(100) |
||||
|
]), |
||||
|
effectiveDate: new FormControl(new Date().toISOString().split('T')[0], [ |
||||
|
Validators.required |
||||
|
]), |
||||
|
acquisitionDate: new FormControl(''), |
||||
|
costBasis: new FormControl(null) |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
if (this.ownershipForm.valid) { |
||||
|
const val = this.ownershipForm.value; |
||||
|
this.dialogRef.close({ |
||||
|
accountId: val.accountId, |
||||
|
ownershipPercent: val.ownershipPercent, |
||||
|
effectiveDate: val.effectiveDate, |
||||
|
...(val.acquisitionDate |
||||
|
? { acquisitionDate: val.acquisitionDate } |
||||
|
: {}), |
||||
|
...(val.costBasis != null ? { costBasis: val.costBasis } : {}) |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
export interface AddOwnershipDialogParams { |
||||
|
entityId: string; |
||||
|
accounts: { id: string; name: string }[]; |
||||
|
} |
||||
@ -0,0 +1,194 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { User } from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { NotificationService } from '@ghostfolio/ui/notifications'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
CUSTOM_ELEMENTS_SCHEMA, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { MatTableModule } from '@angular/material/table'; |
||||
|
import { MatTabsModule } from '@angular/material/tabs'; |
||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { addOutline, arrowBackOutline } from 'ionicons/icons'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
|
||||
|
import { GfAddOwnershipDialogComponent } from './add-ownership-dialog/add-ownership-dialog.component'; |
||||
|
import { AddOwnershipDialogParams } from './add-ownership-dialog/interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
host: { class: 'page' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatTableModule, |
||||
|
MatTabsModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA], |
||||
|
selector: 'gf-entity-detail-page', |
||||
|
styleUrls: ['./entity-detail-page.scss'], |
||||
|
templateUrl: './entity-detail-page.html' |
||||
|
}) |
||||
|
export class GfEntityDetailPageComponent implements OnInit { |
||||
|
public accounts: { id: string; name: string }[] = []; |
||||
|
public deviceType: string; |
||||
|
public distributions: any[] = []; |
||||
|
public distributionColumns = ['partnershipName', 'type', 'amount', 'date']; |
||||
|
public entity: any; |
||||
|
public hasPermissionToCreate = false; |
||||
|
public hasPermissionToDelete = false; |
||||
|
public hasPermissionToUpdate = false; |
||||
|
public kDocuments: any[] = []; |
||||
|
public kDocumentColumns = ['taxYear', 'partnershipName', 'status']; |
||||
|
public membershipColumns = [ |
||||
|
'partnershipName', |
||||
|
'ownershipPercent', |
||||
|
'allocatedNav' |
||||
|
]; |
||||
|
public ownershipColumns = [ |
||||
|
'accountName', |
||||
|
'ownershipPercent', |
||||
|
'effectiveDate' |
||||
|
]; |
||||
|
public user: User; |
||||
|
|
||||
|
private entityId: string; |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private dataService: DataService, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private deviceService: DeviceDetectorService, |
||||
|
private dialog: MatDialog, |
||||
|
private familyOfficeDataService: FamilyOfficeDataService, |
||||
|
private notificationService: NotificationService, |
||||
|
private route: ActivatedRoute, |
||||
|
private router: Router, |
||||
|
private userService: UserService |
||||
|
) { |
||||
|
addIcons({ addOutline, arrowBackOutline }); |
||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.entityId = this.route.snapshot.paramMap.get('id'); |
||||
|
|
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
this.hasPermissionToCreate = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.createEntity |
||||
|
); |
||||
|
this.hasPermissionToUpdate = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.updateEntity |
||||
|
); |
||||
|
this.hasPermissionToDelete = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.deleteEntity |
||||
|
); |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.fetchEntityDetail(); |
||||
|
this.fetchDistributions(); |
||||
|
this.fetchKDocuments(); |
||||
|
this.fetchAccounts(); |
||||
|
} |
||||
|
|
||||
|
public onAddOwnership() { |
||||
|
const dialogRef = this.dialog.open(GfAddOwnershipDialogComponent, { |
||||
|
data: { |
||||
|
entityId: this.entityId, |
||||
|
accounts: this.accounts |
||||
|
} as AddOwnershipDialogParams, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.createOwnership(this.entityId, result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.fetchEntityDetail(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onDeleteEntity() { |
||||
|
this.notificationService.confirm({ |
||||
|
confirmFn: () => { |
||||
|
this.familyOfficeDataService |
||||
|
.deleteEntity(this.entityId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.router.navigate(['/entities']); |
||||
|
}); |
||||
|
}, |
||||
|
confirmType: undefined, |
||||
|
message: $localize`Do you really want to delete this entity?`, |
||||
|
title: $localize`Delete Entity` |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchAccounts() { |
||||
|
this.dataService |
||||
|
.fetchAccounts() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(({ accounts }) => { |
||||
|
this.accounts = accounts.map((a) => ({ id: a.id, name: a.name })); |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchEntityDetail() { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchEntity(this.entityId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((entity) => { |
||||
|
this.entity = entity; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchDistributions() { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchEntityDistributions(this.entityId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result: any) => { |
||||
|
this.distributions = result.distributions ?? []; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchKDocuments() { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchEntityKDocuments(this.entityId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((kDocuments) => { |
||||
|
this.kDocuments = kDocuments; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,256 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<div class="align-items-center d-flex mb-4"> |
||||
|
<a class="me-3" mat-icon-button routerLink="/entities"> |
||||
|
<ion-icon name="arrow-back-outline" /> |
||||
|
</a> |
||||
|
<h1 class="h3 mb-0">{{ entity?.name }}</h1> |
||||
|
<span class="badge bg-primary ms-3">{{ entity?.type }}</span> |
||||
|
@if (entity?.taxId) { |
||||
|
<span class="ms-3 text-muted small"> |
||||
|
Tax ID: {{ entity.taxId }} |
||||
|
</span> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
<mat-tab-group> |
||||
|
<!-- Ownerships Tab --> |
||||
|
<mat-tab> |
||||
|
<ng-template mat-tab-label> |
||||
|
<span i18n>Ownerships</span> |
||||
|
<span class="badge bg-secondary ms-2">{{ |
||||
|
entity?.ownerships?.length ?? 0 |
||||
|
}}</span> |
||||
|
</ng-template> |
||||
|
|
||||
|
<div class="py-3"> |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
[dataSource]="entity?.ownerships ?? []" |
||||
|
> |
||||
|
<ng-container matColumnDef="accountName"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Account</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.accountName }}</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="ownershipPercent"> |
||||
|
<th *matHeaderCellDef class="text-right" mat-header-cell> |
||||
|
Ownership % |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
{{ row.ownershipPercent }}% |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="effectiveDate"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell" |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Effective Date |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ row.effectiveDate | date }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="ownershipColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: ownershipColumns" mat-row></tr> |
||||
|
</table> |
||||
|
|
||||
|
@if (entity?.ownerships?.length === 0) { |
||||
|
<p class="p-3 text-center text-muted" i18n> |
||||
|
No account ownerships yet. |
||||
|
</p> |
||||
|
} |
||||
|
|
||||
|
@if (hasPermissionToCreate) { |
||||
|
<div class="mt-3"> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-stroked-button |
||||
|
(click)="onAddOwnership()" |
||||
|
> |
||||
|
<ion-icon class="me-1" name="add-outline" /> |
||||
|
Add Ownership |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
|
||||
|
<!-- Partnership Memberships Tab --> |
||||
|
<mat-tab> |
||||
|
<ng-template mat-tab-label> |
||||
|
<span i18n>Partnerships</span> |
||||
|
<span class="badge bg-secondary ms-2">{{ |
||||
|
entity?.memberships?.length ?? 0 |
||||
|
}}</span> |
||||
|
</ng-template> |
||||
|
|
||||
|
<div class="py-3"> |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
[dataSource]="entity?.memberships ?? []" |
||||
|
> |
||||
|
<ng-container matColumnDef="partnershipName"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Partnership</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.partnershipName }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="ownershipPercent"> |
||||
|
<th *matHeaderCellDef class="text-right" mat-header-cell> |
||||
|
Ownership % |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
{{ row.ownershipPercent }}% |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="allocatedNav"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Allocated NAV |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ |
||||
|
row.allocatedNav != null |
||||
|
? (row.allocatedNav |
||||
|
| currency: 'USD' : 'symbol' : '1.0-0') |
||||
|
: '—' |
||||
|
}} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="membershipColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: membershipColumns" mat-row></tr> |
||||
|
</table> |
||||
|
|
||||
|
@if (entity?.memberships?.length === 0) { |
||||
|
<p class="p-3 text-center text-muted" i18n> |
||||
|
No partnership memberships yet. |
||||
|
</p> |
||||
|
} |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
|
||||
|
<!-- Distributions Tab --> |
||||
|
<mat-tab> |
||||
|
<ng-template mat-tab-label> |
||||
|
<span i18n>Distributions</span> |
||||
|
<span class="badge bg-secondary ms-2">{{ |
||||
|
distributions?.length ?? 0 |
||||
|
}}</span> |
||||
|
</ng-template> |
||||
|
|
||||
|
<div class="py-3"> |
||||
|
@if (distributions?.length > 0) { |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
[dataSource]="distributions" |
||||
|
> |
||||
|
<ng-container matColumnDef="partnershipName"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Partnership</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.partnership?.name ?? '—' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="type"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Type</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.type }}</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="amount"> |
||||
|
<th *matHeaderCellDef class="text-right" mat-header-cell> |
||||
|
Amount |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
{{ row.amount | currency: 'USD' : 'symbol' : '1.2-2' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="date"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell" |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Date |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ row.date | date }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="distributionColumns" mat-header-row></tr> |
||||
|
<tr |
||||
|
*matRowDef="let row; columns: distributionColumns" |
||||
|
mat-row |
||||
|
></tr> |
||||
|
</table> |
||||
|
} @else { |
||||
|
<p class="p-3 text-center text-muted" i18n> |
||||
|
No distributions yet. |
||||
|
</p> |
||||
|
} |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
|
||||
|
<!-- K-1 Documents Tab --> |
||||
|
<mat-tab> |
||||
|
<ng-template mat-tab-label> |
||||
|
<span i18n>K-1 Documents</span> |
||||
|
<span class="badge bg-secondary ms-2">{{ |
||||
|
kDocuments?.length ?? 0 |
||||
|
}}</span> |
||||
|
</ng-template> |
||||
|
|
||||
|
<div class="py-3"> |
||||
|
@if (kDocuments?.length > 0) { |
||||
|
<table class="gf-table w-100" mat-table [dataSource]="kDocuments"> |
||||
|
<ng-container matColumnDef="taxYear"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Tax Year</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.taxYear }}</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="partnershipName"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Partnership</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.partnership?.name ?? '—' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="status"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Status</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.filingStatus ?? row.status }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="kDocumentColumns" mat-header-row></tr> |
||||
|
<tr |
||||
|
*matRowDef="let row; columns: kDocumentColumns" |
||||
|
mat-row |
||||
|
></tr> |
||||
|
</table> |
||||
|
} @else { |
||||
|
<p class="p-3 text-center text-muted" i18n> |
||||
|
No K-1 documents yet. |
||||
|
</p> |
||||
|
} |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
</mat-tab-group> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { GfEntityDetailPageComponent } from './entity-detail-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: GfEntityDetailPageComponent, |
||||
|
path: '', |
||||
|
title: 'Entity Detail' |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,3 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
@ -0,0 +1,427 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import type { IFamilyOfficeDashboard } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatCardModule } from '@angular/material/card'; |
||||
|
import { MatChipsModule } from '@angular/material/chips'; |
||||
|
import { MatIconModule } from '@angular/material/icon'; |
||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar'; |
||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; |
||||
|
import { MatTableModule } from '@angular/material/table'; |
||||
|
import { RouterModule } from '@angular/router'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatCardModule, |
||||
|
MatChipsModule, |
||||
|
MatIconModule, |
||||
|
MatProgressBarModule, |
||||
|
MatProgressSpinnerModule, |
||||
|
MatTableModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
selector: 'gf-dashboard-page', |
||||
|
standalone: true, |
||||
|
styles: [ |
||||
|
` |
||||
|
:host { |
||||
|
display: block; |
||||
|
padding: 1rem; |
||||
|
} |
||||
|
|
||||
|
.page-header { |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.hero-card { |
||||
|
text-align: center; |
||||
|
margin-bottom: 1.5rem; |
||||
|
padding: 2rem; |
||||
|
} |
||||
|
|
||||
|
.hero-card .aum-value { |
||||
|
font-size: 2.5rem; |
||||
|
font-weight: 700; |
||||
|
color: #1976d2; |
||||
|
} |
||||
|
|
||||
|
.hero-card .aum-label { |
||||
|
font-size: 1rem; |
||||
|
color: rgba(0, 0, 0, 0.6); |
||||
|
margin-top: 0.25rem; |
||||
|
} |
||||
|
|
||||
|
.hero-card .counts { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
gap: 2rem; |
||||
|
margin-top: 1rem; |
||||
|
} |
||||
|
|
||||
|
.hero-card .counts .count-item { |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
.hero-card .counts .count-value { |
||||
|
font-size: 1.5rem; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.hero-card .counts .count-label { |
||||
|
font-size: 0.85rem; |
||||
|
color: rgba(0, 0, 0, 0.6); |
||||
|
} |
||||
|
|
||||
|
.charts-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); |
||||
|
gap: 1rem; |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.allocation-section mat-card-content { |
||||
|
padding-top: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar { |
||||
|
display: flex; |
||||
|
gap: 0.5rem; |
||||
|
margin-bottom: 0.75rem; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar .bar-container { |
||||
|
flex: 1; |
||||
|
height: 24px; |
||||
|
background-color: #e0e0e0; |
||||
|
border-radius: 4px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar .bar { |
||||
|
height: 100%; |
||||
|
border-radius: 4px; |
||||
|
transition: width 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar .label { |
||||
|
min-width: 120px; |
||||
|
font-size: 0.85rem; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar .pct { |
||||
|
font-size: 0.85rem; |
||||
|
min-width: 60px; |
||||
|
text-align: right; |
||||
|
} |
||||
|
|
||||
|
.entity-color { |
||||
|
background-color: #1976d2; |
||||
|
} |
||||
|
|
||||
|
.asset-color { |
||||
|
background-color: #4caf50; |
||||
|
} |
||||
|
|
||||
|
.structure-color { |
||||
|
background-color: #ff9800; |
||||
|
} |
||||
|
|
||||
|
.bottom-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); |
||||
|
gap: 1rem; |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.k1-status .status-row { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 0.5rem; |
||||
|
font-size: 0.9rem; |
||||
|
} |
||||
|
|
||||
|
.k1-status .progress-section { |
||||
|
margin-top: 1rem; |
||||
|
} |
||||
|
|
||||
|
.k1-status .progress-label { |
||||
|
font-size: 0.85rem; |
||||
|
color: rgba(0, 0, 0, 0.6); |
||||
|
margin-bottom: 0.25rem; |
||||
|
} |
||||
|
|
||||
|
.loading-container { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
padding: 3rem; |
||||
|
} |
||||
|
` |
||||
|
], |
||||
|
template: ` |
||||
|
<div class="page-header"> |
||||
|
<h1>Family Office Dashboard</h1> |
||||
|
</div> |
||||
|
|
||||
|
@if (isLoading) { |
||||
|
<div class="loading-container"> |
||||
|
<mat-spinner diameter="48"></mat-spinner> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@if (dashboard) { |
||||
|
<!-- AUM Hero --> |
||||
|
<mat-card class="hero-card"> |
||||
|
<div class="aum-value"> |
||||
|
{{ dashboard.totalAum | number: '1.0-0' }} |
||||
|
<span style="font-size: 1rem; font-weight: 400">{{ |
||||
|
dashboard.currency |
||||
|
}}</span> |
||||
|
</div> |
||||
|
<div class="aum-label">Total Assets Under Management</div> |
||||
|
<div class="counts"> |
||||
|
<div class="count-item"> |
||||
|
<div class="count-value">{{ dashboard.entitiesCount }}</div> |
||||
|
<div class="count-label">Entities</div> |
||||
|
</div> |
||||
|
<div class="count-item"> |
||||
|
<div class="count-value">{{ dashboard.partnershipsCount }}</div> |
||||
|
<div class="count-label">Partnerships</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</mat-card> |
||||
|
|
||||
|
<!-- Allocation Charts --> |
||||
|
<div class="charts-grid"> |
||||
|
<!-- By Entity --> |
||||
|
<mat-card class="allocation-section"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>Allocation by Entity</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
@for (item of dashboard.allocationByEntity; track item.entityId) { |
||||
|
<div class="allocation-bar"> |
||||
|
<span class="label">{{ item.entityName }}</span> |
||||
|
<div class="bar-container"> |
||||
|
<div |
||||
|
class="bar entity-color" |
||||
|
[style.width.%]="item.percentage" |
||||
|
></div> |
||||
|
</div> |
||||
|
<span class="pct" |
||||
|
>{{ item.percentage | number: '1.1-1' }}%</span |
||||
|
> |
||||
|
</div> |
||||
|
} |
||||
|
@if (dashboard.allocationByEntity.length === 0) { |
||||
|
<p style="color: rgba(0,0,0,0.4); text-align: center"> |
||||
|
No allocation data |
||||
|
</p> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<!-- By Asset Class --> |
||||
|
<mat-card class="allocation-section"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>Allocation by Asset Class</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
@for ( |
||||
|
item of dashboard.allocationByAssetClass; |
||||
|
track item.assetClass |
||||
|
) { |
||||
|
<div class="allocation-bar"> |
||||
|
<span class="label">{{ item.assetClass }}</span> |
||||
|
<div class="bar-container"> |
||||
|
<div |
||||
|
class="bar asset-color" |
||||
|
[style.width.%]="item.percentage" |
||||
|
></div> |
||||
|
</div> |
||||
|
<span class="pct" |
||||
|
>{{ item.percentage | number: '1.1-1' }}%</span |
||||
|
> |
||||
|
</div> |
||||
|
} |
||||
|
@if (dashboard.allocationByAssetClass.length === 0) { |
||||
|
<p style="color: rgba(0,0,0,0.4); text-align: center"> |
||||
|
No asset data |
||||
|
</p> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<!-- By Structure --> |
||||
|
<mat-card class="allocation-section"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>Allocation by Structure</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
@for ( |
||||
|
item of dashboard.allocationByStructure; |
||||
|
track item.structureType |
||||
|
) { |
||||
|
<div class="allocation-bar"> |
||||
|
<span class="label">{{ item.structureType }}</span> |
||||
|
<div class="bar-container"> |
||||
|
<div |
||||
|
class="bar structure-color" |
||||
|
[style.width.%]="item.percentage" |
||||
|
></div> |
||||
|
</div> |
||||
|
<span class="pct" |
||||
|
>{{ item.percentage | number: '1.1-1' }}%</span |
||||
|
> |
||||
|
</div> |
||||
|
} |
||||
|
@if (dashboard.allocationByStructure.length === 0) { |
||||
|
<p style="color: rgba(0,0,0,0.4); text-align: center"> |
||||
|
No structure data |
||||
|
</p> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Bottom row: Recent Distributions + K-1 Status --> |
||||
|
<div class="bottom-grid"> |
||||
|
<!-- Recent Distributions --> |
||||
|
<mat-card> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>Recent Distributions</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
@if (dashboard.recentDistributions.length > 0) { |
||||
|
<table |
||||
|
class="mat-elevation-z0" |
||||
|
mat-table |
||||
|
[dataSource]="dashboard.recentDistributions" |
||||
|
> |
||||
|
<ng-container matColumnDef="partnership"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Partnership</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.partnershipName }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="amount"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Amount</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.amount | number: '1.0-0' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="date"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Date</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.date }}</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="type"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Type</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
<mat-chip>{{ row.type }}</mat-chip> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="distributionColumns" mat-header-row></tr> |
||||
|
<tr |
||||
|
*matRowDef="let row; columns: distributionColumns" |
||||
|
mat-row |
||||
|
></tr> |
||||
|
</table> |
||||
|
} @else { |
||||
|
<p style="color: rgba(0,0,0,0.4); text-align: center"> |
||||
|
No recent distributions |
||||
|
</p> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<!-- K-1 Status --> |
||||
|
<mat-card class="k1-status"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title |
||||
|
>K-1 Filing Status ({{ |
||||
|
dashboard.kDocumentStatus.taxYear |
||||
|
}})</mat-card-title |
||||
|
> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<div class="status-row"> |
||||
|
<span>Total K-1 Documents</span> |
||||
|
<strong>{{ dashboard.kDocumentStatus.total }}</strong> |
||||
|
</div> |
||||
|
<div class="status-row"> |
||||
|
<span>Draft</span> |
||||
|
<strong>{{ dashboard.kDocumentStatus.draft }}</strong> |
||||
|
</div> |
||||
|
<div class="status-row"> |
||||
|
<span>Estimated</span> |
||||
|
<strong>{{ dashboard.kDocumentStatus.estimated }}</strong> |
||||
|
</div> |
||||
|
<div class="status-row"> |
||||
|
<span>Final</span> |
||||
|
<strong>{{ dashboard.kDocumentStatus.final }}</strong> |
||||
|
</div> |
||||
|
@if (dashboard.kDocumentStatus.total > 0) { |
||||
|
<div class="progress-section"> |
||||
|
<div class="progress-label"> |
||||
|
{{ k1ProgressPercent | number: '1.0-0' }}% Complete (Final) |
||||
|
</div> |
||||
|
<mat-progress-bar |
||||
|
color="primary" |
||||
|
mode="determinate" |
||||
|
[value]="k1ProgressPercent" |
||||
|
></mat-progress-bar> |
||||
|
</div> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
</div> |
||||
|
} |
||||
|
` |
||||
|
}) |
||||
|
export class DashboardPageComponent implements OnInit { |
||||
|
public dashboard: IFamilyOfficeDashboard | null = null; |
||||
|
public distributionColumns = ['partnership', 'amount', 'date', 'type']; |
||||
|
public isLoading = true; |
||||
|
public k1ProgressPercent = 0; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly changeDetectorRef: ChangeDetectorRef, |
||||
|
private readonly destroyRef: DestroyRef, |
||||
|
private readonly familyOfficeDataService: FamilyOfficeDataService |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchDashboard() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe({ |
||||
|
error: () => { |
||||
|
this.isLoading = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}, |
||||
|
next: (dashboard) => { |
||||
|
this.dashboard = dashboard; |
||||
|
this.isLoading = false; |
||||
|
|
||||
|
if (dashboard.kDocumentStatus.total > 0) { |
||||
|
this.k1ProgressPercent = |
||||
|
(dashboard.kDocumentStatus.final / |
||||
|
dashboard.kDocumentStatus.total) * |
||||
|
100; |
||||
|
} |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { DashboardPageComponent } from './dashboard-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: DashboardPageComponent, |
||||
|
path: '', |
||||
|
title: $localize`Family Office Dashboard` |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,185 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import type { IKDocument } from '@ghostfolio/common/interfaces'; |
||||
|
import { GfKDocumentFormComponent } from '@ghostfolio/ui/k-document-form'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { FormsModule } from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatMenuModule } from '@angular/material/menu'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
import { MatSortModule } from '@angular/material/sort'; |
||||
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { |
||||
|
addOutline, |
||||
|
arrowBackOutline, |
||||
|
ellipsisVerticalOutline |
||||
|
} from 'ionicons/icons'; |
||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'has-fab page' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
GfKDocumentFormComponent, |
||||
|
MatButtonModule, |
||||
|
MatFormFieldModule, |
||||
|
MatMenuModule, |
||||
|
MatSelectModule, |
||||
|
MatSortModule, |
||||
|
MatTableModule, |
||||
|
NgxSkeletonLoaderModule |
||||
|
], |
||||
|
selector: 'gf-k-documents-page', |
||||
|
styleUrls: ['./k-documents-page.scss'], |
||||
|
templateUrl: './k-documents-page.html' |
||||
|
}) |
||||
|
export class KDocumentsPageComponent implements OnInit { |
||||
|
public dataSource = new MatTableDataSource<IKDocument>(); |
||||
|
public displayedColumns = [ |
||||
|
'partnershipName', |
||||
|
'type', |
||||
|
'taxYear', |
||||
|
'filingStatus', |
||||
|
'ordinaryIncome', |
||||
|
'actions' |
||||
|
]; |
||||
|
public editingDoc: IKDocument | null = null; |
||||
|
public filterPartnershipId: string | null = null; |
||||
|
public filterStatus: string | null = null; |
||||
|
public filterTaxYear: number | null = null; |
||||
|
public isLoading = true; |
||||
|
public kDocuments: IKDocument[] = []; |
||||
|
public newDocPartnershipId: string = ''; |
||||
|
public newDocTaxYear: number; |
||||
|
public newDocType: string = 'K1'; |
||||
|
public partnerships: { id: string; name: string }[] = []; |
||||
|
public showForm = false; |
||||
|
public taxYearOptions: number[] = []; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly changeDetectorRef: ChangeDetectorRef, |
||||
|
private readonly destroyRef: DestroyRef, |
||||
|
private readonly familyOfficeDataService: FamilyOfficeDataService |
||||
|
) { |
||||
|
addIcons({ addOutline, arrowBackOutline, ellipsisVerticalOutline }); |
||||
|
|
||||
|
const currentYear = new Date().getFullYear(); |
||||
|
this.newDocTaxYear = currentYear - 1; |
||||
|
|
||||
|
for (let y = currentYear; y >= currentYear - 10; y--) { |
||||
|
this.taxYearOptions.push(y); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public ngOnInit(): void { |
||||
|
this.fetchPartnerships(); |
||||
|
this.fetchKDocuments(); |
||||
|
} |
||||
|
|
||||
|
public cancelForm(): void { |
||||
|
this.showForm = false; |
||||
|
this.editingDoc = null; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
|
||||
|
public editDoc(doc: IKDocument): void { |
||||
|
this.editingDoc = doc; |
||||
|
this.showForm = true; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
|
||||
|
public fetchKDocuments(): void { |
||||
|
this.isLoading = true; |
||||
|
const params: Record<string, any> = {}; |
||||
|
|
||||
|
if (this.filterPartnershipId) { |
||||
|
params.partnershipId = this.filterPartnershipId; |
||||
|
} |
||||
|
|
||||
|
if (this.filterTaxYear) { |
||||
|
params.taxYear = this.filterTaxYear; |
||||
|
} |
||||
|
|
||||
|
if (this.filterStatus) { |
||||
|
params.filingStatus = this.filterStatus; |
||||
|
} |
||||
|
|
||||
|
this.familyOfficeDataService |
||||
|
.fetchKDocuments(params) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((docs) => { |
||||
|
this.kDocuments = docs; |
||||
|
this.dataSource.data = docs; |
||||
|
this.isLoading = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onFormSubmit(event: { |
||||
|
filingStatus: string; |
||||
|
data: Record<string, number>; |
||||
|
}): void { |
||||
|
if (this.editingDoc) { |
||||
|
this.familyOfficeDataService |
||||
|
.updateKDocument(this.editingDoc.id, { |
||||
|
data: event.data, |
||||
|
filingStatus: event.filingStatus |
||||
|
}) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.showForm = false; |
||||
|
this.editingDoc = null; |
||||
|
this.fetchKDocuments(); |
||||
|
}); |
||||
|
} else { |
||||
|
if (!this.newDocPartnershipId) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.familyOfficeDataService |
||||
|
.createKDocument({ |
||||
|
data: event.data, |
||||
|
filingStatus: event.filingStatus, |
||||
|
partnershipId: this.newDocPartnershipId, |
||||
|
taxYear: this.newDocTaxYear, |
||||
|
type: this.newDocType |
||||
|
}) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.showForm = false; |
||||
|
this.fetchKDocuments(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public startCreate(): void { |
||||
|
this.editingDoc = null; |
||||
|
this.showForm = true; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
|
||||
|
private fetchPartnerships(): void { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchPartnerships() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((partnerships) => { |
||||
|
this.partnerships = partnerships.map((p) => ({ |
||||
|
id: p.id, |
||||
|
name: p.name |
||||
|
})); |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,199 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n> |
||||
|
K-1 / K-3 Documents |
||||
|
</h1> |
||||
|
|
||||
|
@if (showForm) { |
||||
|
<div class="form-container"> |
||||
|
<div class="align-items-center d-flex mb-3"> |
||||
|
<button mat-icon-button (click)="cancelForm()"> |
||||
|
<ion-icon name="arrow-back-outline" /> |
||||
|
</button> |
||||
|
<h2 class="h5 mb-0 ms-2"> |
||||
|
{{ editingDoc ? 'Edit K-Document' : 'New K-Document' }} |
||||
|
</h2> |
||||
|
</div> |
||||
|
|
||||
|
@if (!editingDoc) { |
||||
|
<div class="align-items-center d-flex flex-wrap gap-3 mb-3"> |
||||
|
<mat-form-field appearance="outline" class="filter-field"> |
||||
|
<mat-label i18n>Partnership</mat-label> |
||||
|
<mat-select [(value)]="newDocPartnershipId"> |
||||
|
@for (p of partnerships; track p.id) { |
||||
|
<mat-option [value]="p.id">{{ p.name }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" class="filter-field"> |
||||
|
<mat-label i18n>Type</mat-label> |
||||
|
<mat-select [(value)]="newDocType"> |
||||
|
<mat-option value="K1">K-1</mat-option> |
||||
|
<mat-option value="K3">K-3</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" class="filter-field"> |
||||
|
<mat-label i18n>Tax Year</mat-label> |
||||
|
<mat-select [(value)]="newDocTaxYear"> |
||||
|
@for (year of taxYearOptions; track year) { |
||||
|
<mat-option [value]="year">{{ year }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<gf-k-document-form |
||||
|
[data]="editingDoc?.data || null" |
||||
|
[filingStatus]="editingDoc?.filingStatus || 'DRAFT'" |
||||
|
[isEditMode]="!!editingDoc" |
||||
|
(cancelled)="cancelForm()" |
||||
|
(submitted)="onFormSubmit($event)" |
||||
|
/> |
||||
|
</div> |
||||
|
} @else { |
||||
|
<div class="align-items-center d-flex flex-wrap gap-3 mb-3"> |
||||
|
<mat-form-field appearance="outline" class="filter-field"> |
||||
|
<mat-label i18n>Partnership</mat-label> |
||||
|
<mat-select |
||||
|
[(value)]="filterPartnershipId" |
||||
|
(selectionChange)="fetchKDocuments()" |
||||
|
> |
||||
|
<mat-option [value]="null">All</mat-option> |
||||
|
@for (p of partnerships; track p.id) { |
||||
|
<mat-option [value]="p.id">{{ p.name }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" class="filter-field"> |
||||
|
<mat-label i18n>Tax Year</mat-label> |
||||
|
<mat-select |
||||
|
[(value)]="filterTaxYear" |
||||
|
(selectionChange)="fetchKDocuments()" |
||||
|
> |
||||
|
<mat-option [value]="null">All</mat-option> |
||||
|
@for (year of taxYearOptions; track year) { |
||||
|
<mat-option [value]="year">{{ year }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" class="filter-field"> |
||||
|
<mat-label i18n>Status</mat-label> |
||||
|
<mat-select |
||||
|
[(value)]="filterStatus" |
||||
|
(selectionChange)="fetchKDocuments()" |
||||
|
> |
||||
|
<mat-option [value]="null">All</mat-option> |
||||
|
<mat-option value="DRAFT">Draft</mat-option> |
||||
|
<mat-option value="ESTIMATED">Estimated</mat-option> |
||||
|
<mat-option value="FINAL">Final</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
@if (isLoading) { |
||||
|
<ngx-skeleton-loader |
||||
|
animation="pulse" |
||||
|
[theme]="{ height: '3rem', width: '100%' }" |
||||
|
/> |
||||
|
} @else if (kDocuments.length === 0) { |
||||
|
<p class="p-3 text-center text-muted" i18n>No K-documents found.</p> |
||||
|
} @else { |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
matSort |
||||
|
[dataSource]="dataSource" |
||||
|
> |
||||
|
<ng-container matColumnDef="partnershipName"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header> |
||||
|
Partnership |
||||
|
</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.partnershipName }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="type"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Type</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.type }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="taxYear"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header> |
||||
|
Tax Year |
||||
|
</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.taxYear }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="filingStatus"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Status</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
<span |
||||
|
class="status-badge" |
||||
|
[ngClass]="'status-' + row.filingStatus" |
||||
|
> |
||||
|
{{ row.filingStatus }} |
||||
|
</span> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="ordinaryIncome"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
i18n |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Ordinary Income |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ |
||||
|
row.data?.ordinaryIncome |
||||
|
| currency: 'USD' : 'symbol' : '1.0-0' |
||||
|
}} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="actions"> |
||||
|
<th *matHeaderCellDef class="text-right" mat-header-cell></th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
<button mat-icon-button [matMenuTriggerFor]="actionMenu"> |
||||
|
<ion-icon name="ellipsis-vertical-outline" /> |
||||
|
</button> |
||||
|
<mat-menu #actionMenu="matMenu"> |
||||
|
<button mat-menu-item (click)="editDoc(row)">Edit</button> |
||||
|
</mat-menu> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
||||
|
<tr |
||||
|
*matRowDef="let row; columns: displayedColumns" |
||||
|
class="cursor-pointer" |
||||
|
mat-row |
||||
|
(click)="editDoc(row)" |
||||
|
></tr> |
||||
|
</table> |
||||
|
} |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
@if (!showForm) { |
||||
|
<div class="fab-container"> |
||||
|
<a mat-fab (click)="startCreate()"> |
||||
|
<ion-icon name="add-outline" size="large" /> |
||||
|
</a> |
||||
|
</div> |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { KDocumentsPageComponent } from './k-documents-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: KDocumentsPageComponent, |
||||
|
path: '', |
||||
|
title: 'K-1 / K-3 Documents' |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,39 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
.filter-field { |
||||
|
min-width: 10rem; |
||||
|
} |
||||
|
|
||||
|
.form-container { |
||||
|
max-width: 900px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
.status-badge { |
||||
|
border-radius: 12px; |
||||
|
display: inline-block; |
||||
|
font-size: 12px; |
||||
|
font-weight: 500; |
||||
|
padding: 2px 8px; |
||||
|
} |
||||
|
|
||||
|
.status-DRAFT { |
||||
|
background: #e0e0e0; |
||||
|
color: #616161; |
||||
|
} |
||||
|
|
||||
|
.status-ESTIMATED { |
||||
|
background: #fff3e0; |
||||
|
color: #e65100; |
||||
|
} |
||||
|
|
||||
|
.status-FINAL { |
||||
|
background: #e8f5e9; |
||||
|
color: #2e7d32; |
||||
|
} |
||||
|
|
||||
|
.cursor-pointer { |
||||
|
cursor: pointer; |
||||
|
} |
||||
@ -0,0 +1,152 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
Inject, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
ReactiveFormsModule, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { |
||||
|
MAT_DIALOG_DATA, |
||||
|
MatDialogModule, |
||||
|
MatDialogRef |
||||
|
} from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-add-asset-dialog', |
||||
|
template: ` |
||||
|
<form |
||||
|
class="d-flex flex-column h-100" |
||||
|
[formGroup]="assetForm" |
||||
|
(keyup.enter)="assetForm.valid && onSubmit()" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
<h1 i18n mat-dialog-title>Add Asset</h1> |
||||
|
|
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Name</mat-label> |
||||
|
<input formControlName="name" matInput required /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Asset Type</mat-label> |
||||
|
<mat-select formControlName="assetType" required> |
||||
|
<mat-option value="REAL_ESTATE">Real Estate</mat-option> |
||||
|
<mat-option value="PRIVATE_EQUITY">Private Equity</mat-option> |
||||
|
<mat-option value="VENTURE_CAPITAL">Venture Capital</mat-option> |
||||
|
<mat-option value="HEDGE_FUND">Hedge Fund</mat-option> |
||||
|
<mat-option value="FIXED_INCOME">Fixed Income</mat-option> |
||||
|
<mat-option value="COMMODITY">Commodity</mat-option> |
||||
|
<mat-option value="OTHER">Other</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Currency</mat-label> |
||||
|
<input formControlName="currency" matInput required /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Acquisition Date</mat-label> |
||||
|
<input formControlName="acquisitionDate" matInput type="date" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Acquisition Cost</mat-label> |
||||
|
<input formControlName="acquisitionCost" matInput type="number" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Current Value</mat-label> |
||||
|
<input formControlName="currentValue" matInput type="number" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Description</mat-label> |
||||
|
<input formControlName="description" matInput /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button type="button" (click)="onCancel()"> |
||||
|
Cancel |
||||
|
</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="!assetForm.valid" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
` |
||||
|
}) |
||||
|
export class GfAddAssetDialogComponent implements OnInit { |
||||
|
public assetForm: FormGroup; |
||||
|
|
||||
|
public constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) public data: { currency: string }, |
||||
|
public dialogRef: MatDialogRef<GfAddAssetDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.assetForm = new FormGroup({ |
||||
|
name: new FormControl('', [Validators.required]), |
||||
|
assetType: new FormControl('REAL_ESTATE', [Validators.required]), |
||||
|
currency: new FormControl(this.data.currency || 'USD', [ |
||||
|
Validators.required |
||||
|
]), |
||||
|
acquisitionDate: new FormControl(''), |
||||
|
acquisitionCost: new FormControl(null), |
||||
|
currentValue: new FormControl(null), |
||||
|
description: new FormControl('') |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
if (this.assetForm.valid) { |
||||
|
this.dialogRef.close(this.assetForm.value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,165 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
Inject, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
ReactiveFormsModule, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { |
||||
|
MAT_DIALOG_DATA, |
||||
|
MatDialogModule, |
||||
|
MatDialogRef |
||||
|
} from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-add-member-dialog', |
||||
|
template: ` |
||||
|
<form |
||||
|
class="d-flex flex-column h-100" |
||||
|
[formGroup]="memberForm" |
||||
|
(keyup.enter)="memberForm.valid && onSubmit()" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
<h1 i18n mat-dialog-title>Add Member</h1> |
||||
|
|
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Entity</mat-label> |
||||
|
<mat-select formControlName="entityId" required> |
||||
|
@for (entity of data.entities; track entity.id) { |
||||
|
<mat-option [value]="entity.id">{{ entity.name }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Ownership %</mat-label> |
||||
|
<input |
||||
|
formControlName="ownershipPercent" |
||||
|
matInput |
||||
|
max="100" |
||||
|
min="0" |
||||
|
required |
||||
|
type="number" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Capital Commitment</mat-label> |
||||
|
<input formControlName="capitalCommitment" matInput type="number" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Capital Contributed</mat-label> |
||||
|
<input |
||||
|
formControlName="capitalContributed" |
||||
|
matInput |
||||
|
type="number" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Class Type</mat-label> |
||||
|
<mat-select formControlName="classType"> |
||||
|
<mat-option value="GP">GP</mat-option> |
||||
|
<mat-option value="LP">LP</mat-option> |
||||
|
<mat-option value="CLASS_A">Class A</mat-option> |
||||
|
<mat-option value="CLASS_B">Class B</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Effective Date</mat-label> |
||||
|
<input |
||||
|
formControlName="effectiveDate" |
||||
|
matInput |
||||
|
required |
||||
|
type="date" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button type="button" (click)="onCancel()"> |
||||
|
Cancel |
||||
|
</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="!memberForm.valid" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
` |
||||
|
}) |
||||
|
export class GfAddMemberDialogComponent implements OnInit { |
||||
|
public memberForm: FormGroup; |
||||
|
|
||||
|
public constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) public data: { entities: any[] }, |
||||
|
public dialogRef: MatDialogRef<GfAddMemberDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.memberForm = new FormGroup({ |
||||
|
entityId: new FormControl('', [Validators.required]), |
||||
|
ownershipPercent: new FormControl(null, [ |
||||
|
Validators.required, |
||||
|
Validators.min(0), |
||||
|
Validators.max(100) |
||||
|
]), |
||||
|
capitalCommitment: new FormControl(null), |
||||
|
capitalContributed: new FormControl(null), |
||||
|
classType: new FormControl('LP'), |
||||
|
effectiveDate: new FormControl(new Date().toISOString().split('T')[0], [ |
||||
|
Validators.required |
||||
|
]) |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
if (this.memberForm.valid) { |
||||
|
this.dialogRef.close(this.memberForm.value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,222 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { User } from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { NotificationService } from '@ghostfolio/ui/notifications'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
CUSTOM_ELEMENTS_SCHEMA, |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { MatTableModule } from '@angular/material/table'; |
||||
|
import { MatTabsModule } from '@angular/material/tabs'; |
||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { addOutline, arrowBackOutline } from 'ionicons/icons'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
|
||||
|
import { GfAddAssetDialogComponent } from './add-asset-dialog/add-asset-dialog.component'; |
||||
|
import { GfAddMemberDialogComponent } from './add-member-dialog/add-member-dialog.component'; |
||||
|
import { GfRecordValuationDialogComponent } from './record-valuation-dialog/record-valuation-dialog.component'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'page' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatTableModule, |
||||
|
MatTabsModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA], |
||||
|
selector: 'gf-partnership-detail-page', |
||||
|
styleUrls: ['./partnership-detail-page.scss'], |
||||
|
templateUrl: './partnership-detail-page.html' |
||||
|
}) |
||||
|
export class GfPartnershipDetailPageComponent implements OnInit { |
||||
|
public assetColumns = [ |
||||
|
'name', |
||||
|
'assetType', |
||||
|
'acquisitionCost', |
||||
|
'currentValue' |
||||
|
]; |
||||
|
public deviceType: string; |
||||
|
public entities: any[] = []; |
||||
|
public hasPermissionToDelete = false; |
||||
|
public hasPermissionToUpdate = false; |
||||
|
public memberColumns = [ |
||||
|
'entityName', |
||||
|
'classType', |
||||
|
'ownershipPercent', |
||||
|
'capitalCommitment', |
||||
|
'capitalContributed' |
||||
|
]; |
||||
|
public partnership: any; |
||||
|
public user: User; |
||||
|
public valuationColumns = ['date', 'nav', 'source']; |
||||
|
public valuations: any[] = []; |
||||
|
|
||||
|
private partnershipId: string; |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private deviceService: DeviceDetectorService, |
||||
|
private dialog: MatDialog, |
||||
|
private familyOfficeDataService: FamilyOfficeDataService, |
||||
|
private notificationService: NotificationService, |
||||
|
private route: ActivatedRoute, |
||||
|
private router: Router, |
||||
|
private userService: UserService |
||||
|
) { |
||||
|
addIcons({ addOutline, arrowBackOutline }); |
||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.partnershipId = this.route.snapshot.paramMap.get('id'); |
||||
|
|
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
this.hasPermissionToUpdate = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.updatePartnership |
||||
|
); |
||||
|
this.hasPermissionToDelete = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.deletePartnership |
||||
|
); |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.fetchPartnershipDetail(); |
||||
|
this.fetchValuations(); |
||||
|
this.fetchEntities(); |
||||
|
} |
||||
|
|
||||
|
public onAddMember() { |
||||
|
const dialogRef = this.dialog.open(GfAddMemberDialogComponent, { |
||||
|
data: { entities: this.entities }, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.addPartnershipMember(this.partnershipId, result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.fetchPartnershipDetail(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onAddAsset() { |
||||
|
const dialogRef = this.dialog.open(GfAddAssetDialogComponent, { |
||||
|
data: { currency: this.partnership?.currency ?? 'USD' }, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.addPartnershipAsset(this.partnershipId, result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.fetchPartnershipDetail(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onRecordValuation() { |
||||
|
const dialogRef = this.dialog.open(GfRecordValuationDialogComponent, { |
||||
|
data: { currency: this.partnership?.currency ?? 'USD' }, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.createPartnershipValuation(this.partnershipId, result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.fetchPartnershipDetail(); |
||||
|
this.fetchValuations(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onDeletePartnership() { |
||||
|
this.notificationService.confirm({ |
||||
|
confirmFn: () => { |
||||
|
this.familyOfficeDataService |
||||
|
.deletePartnership(this.partnershipId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.router.navigate(['/partnerships']); |
||||
|
}); |
||||
|
}, |
||||
|
confirmType: undefined, |
||||
|
message: $localize`Do you really want to delete this partnership?`, |
||||
|
title: $localize`Delete Partnership` |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchEntities() { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchEntities() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((entities) => { |
||||
|
this.entities = entities; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchPartnershipDetail() { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchPartnership(this.partnershipId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((partnership) => { |
||||
|
this.partnership = partnership; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchValuations() { |
||||
|
this.familyOfficeDataService |
||||
|
.fetchPartnershipValuations(this.partnershipId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((valuations) => { |
||||
|
this.valuations = valuations; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,278 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<div class="align-items-center d-flex mb-4"> |
||||
|
<a class="me-3" mat-icon-button routerLink="/partnerships"> |
||||
|
<ion-icon name="arrow-back-outline" /> |
||||
|
</a> |
||||
|
<h1 class="h3 mb-0">{{ partnership?.name }}</h1> |
||||
|
<span class="badge bg-primary ms-3">{{ partnership?.type }}</span> |
||||
|
@if (partnership?.currency) { |
||||
|
<span class="ms-3 text-muted small"> |
||||
|
{{ partnership.currency }} |
||||
|
</span> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
<mat-tab-group> |
||||
|
<!-- Members Tab --> |
||||
|
<mat-tab> |
||||
|
<ng-template mat-tab-label> |
||||
|
<span i18n>Members</span> |
||||
|
<span class="badge bg-secondary ms-2">{{ |
||||
|
partnership?.members?.length ?? 0 |
||||
|
}}</span> |
||||
|
</ng-template> |
||||
|
|
||||
|
<div class="py-3"> |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
[dataSource]="partnership?.members ?? []" |
||||
|
> |
||||
|
<ng-container matColumnDef="entityName"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Entity</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.entityName }}</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="classType"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Class</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.classType }}</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="ownershipPercent"> |
||||
|
<th *matHeaderCellDef class="text-right" i18n mat-header-cell> |
||||
|
Ownership % |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
{{ row.ownershipPercent }}% |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="capitalCommitment"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
i18n |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Commitment |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ |
||||
|
row.capitalCommitment != null |
||||
|
? (row.capitalCommitment |
||||
|
| currency: partnership?.currency : 'symbol' : '1.0-0') |
||||
|
: '—' |
||||
|
}} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="capitalContributed"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
i18n |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Contributed |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ |
||||
|
row.capitalContributed != null |
||||
|
? (row.capitalContributed |
||||
|
| currency: partnership?.currency : 'symbol' : '1.0-0') |
||||
|
: '—' |
||||
|
}} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="memberColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: memberColumns" mat-row></tr> |
||||
|
</table> |
||||
|
|
||||
|
@if (partnership?.members?.length === 0) { |
||||
|
<p class="p-3 text-center text-muted" i18n>No members yet.</p> |
||||
|
} |
||||
|
|
||||
|
@if (hasPermissionToUpdate) { |
||||
|
<div class="mt-3"> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-stroked-button |
||||
|
(click)="onAddMember()" |
||||
|
> |
||||
|
<ion-icon class="me-1" name="add-outline" /> |
||||
|
Add Member |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
|
||||
|
<!-- Assets Tab --> |
||||
|
<mat-tab> |
||||
|
<ng-template mat-tab-label> |
||||
|
<span i18n>Assets</span> |
||||
|
<span class="badge bg-secondary ms-2">{{ |
||||
|
partnership?.assets?.length ?? 0 |
||||
|
}}</span> |
||||
|
</ng-template> |
||||
|
|
||||
|
<div class="py-3"> |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
[dataSource]="partnership?.assets ?? []" |
||||
|
> |
||||
|
<ng-container matColumnDef="name"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Name</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.name }}</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="assetType"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Type</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.assetType }}</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="acquisitionCost"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
i18n |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Acquisition Cost |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ |
||||
|
row.acquisitionCost != null |
||||
|
? (row.acquisitionCost |
||||
|
| currency |
||||
|
: row.currency ?? partnership?.currency |
||||
|
: 'symbol' |
||||
|
: '1.0-0') |
||||
|
: '—' |
||||
|
}} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="currentValue"> |
||||
|
<th *matHeaderCellDef class="text-right" i18n mat-header-cell> |
||||
|
Current Value |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
{{ |
||||
|
row.currentValue != null |
||||
|
? (row.currentValue |
||||
|
| currency |
||||
|
: row.currency ?? partnership?.currency |
||||
|
: 'symbol' |
||||
|
: '1.0-0') |
||||
|
: '—' |
||||
|
}} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="assetColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: assetColumns" mat-row></tr> |
||||
|
</table> |
||||
|
|
||||
|
@if (partnership?.assets?.length === 0) { |
||||
|
<p class="p-3 text-center text-muted" i18n>No assets yet.</p> |
||||
|
} |
||||
|
|
||||
|
@if (hasPermissionToUpdate) { |
||||
|
<div class="mt-3"> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-stroked-button |
||||
|
(click)="onAddAsset()" |
||||
|
> |
||||
|
<ion-icon class="me-1" name="add-outline" /> |
||||
|
Add Asset |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
|
||||
|
<!-- NAV History Tab --> |
||||
|
<mat-tab> |
||||
|
<ng-template mat-tab-label> |
||||
|
<span i18n>NAV History</span> |
||||
|
<span class="badge bg-secondary ms-2">{{ |
||||
|
valuations?.length ?? 0 |
||||
|
}}</span> |
||||
|
</ng-template> |
||||
|
|
||||
|
<div class="py-3"> |
||||
|
<table class="gf-table w-100" mat-table [dataSource]="valuations"> |
||||
|
<ng-container matColumnDef="date"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Date</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.date | date }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="nav"> |
||||
|
<th *matHeaderCellDef class="text-right" i18n mat-header-cell> |
||||
|
NAV |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
{{ |
||||
|
row.nav |
||||
|
| currency |
||||
|
: partnership?.currency ?? 'USD' |
||||
|
: 'symbol' |
||||
|
: '1.0-0' |
||||
|
}} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="source"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell" |
||||
|
i18n |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Source |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ row.source }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="valuationColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: valuationColumns" mat-row></tr> |
||||
|
</table> |
||||
|
|
||||
|
@if (valuations?.length === 0) { |
||||
|
<p class="p-3 text-center text-muted" i18n> |
||||
|
No valuations recorded. |
||||
|
</p> |
||||
|
} |
||||
|
|
||||
|
@if (hasPermissionToUpdate) { |
||||
|
<div class="mt-3"> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-stroked-button |
||||
|
(click)="onRecordValuation()" |
||||
|
> |
||||
|
<ion-icon class="me-1" name="add-outline" /> |
||||
|
Record Valuation |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
</mat-tab-group> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { GfPartnershipDetailPageComponent } from './partnership-detail-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: GfPartnershipDetailPageComponent, |
||||
|
path: '', |
||||
|
title: 'Partnership Detail' |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,3 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
@ -0,0 +1,125 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
Inject, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
ReactiveFormsModule, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { |
||||
|
MAT_DIALOG_DATA, |
||||
|
MatDialogModule, |
||||
|
MatDialogRef |
||||
|
} from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-record-valuation-dialog', |
||||
|
template: ` |
||||
|
<form |
||||
|
class="d-flex flex-column h-100" |
||||
|
[formGroup]="valuationForm" |
||||
|
(keyup.enter)="valuationForm.valid && onSubmit()" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
<h1 i18n mat-dialog-title>Record Valuation</h1> |
||||
|
|
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Date</mat-label> |
||||
|
<input formControlName="date" matInput required type="date" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>NAV</mat-label> |
||||
|
<input formControlName="nav" matInput required type="number" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Source</mat-label> |
||||
|
<mat-select formControlName="source" required> |
||||
|
<mat-option value="STATEMENT">Statement</mat-option> |
||||
|
<mat-option value="MANUAL">Manual Entry</mat-option> |
||||
|
<mat-option value="AUDITOR">Auditor</mat-option> |
||||
|
<mat-option value="FUND_ADMIN">Fund Admin</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Notes</mat-label> |
||||
|
<input formControlName="notes" matInput placeholder="Optional" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button type="button" (click)="onCancel()"> |
||||
|
Cancel |
||||
|
</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="!valuationForm.valid" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
` |
||||
|
}) |
||||
|
export class GfRecordValuationDialogComponent implements OnInit { |
||||
|
public valuationForm: FormGroup; |
||||
|
|
||||
|
public constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) public data: any, |
||||
|
public dialogRef: MatDialogRef<GfRecordValuationDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.valuationForm = new FormGroup({ |
||||
|
date: new FormControl(new Date().toISOString().split('T')[0], [ |
||||
|
Validators.required |
||||
|
]), |
||||
|
nav: new FormControl(null, [Validators.required, Validators.min(0)]), |
||||
|
source: new FormControl('STATEMENT', [Validators.required]), |
||||
|
notes: new FormControl('') |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
if (this.valuationForm.valid) { |
||||
|
this.dialogRef.close(this.valuationForm.value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,247 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import type { IPartnershipPerformance } from '@ghostfolio/common/interfaces'; |
||||
|
import { GfBenchmarkComparisonChartComponent } from '@ghostfolio/ui/benchmark-comparison-chart'; |
||||
|
import { GfPerformanceMetricsComponent } from '@ghostfolio/ui/performance-metrics'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { FormsModule } from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatCardModule } from '@angular/material/card'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatIconModule } from '@angular/material/icon'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
import { MatTableModule } from '@angular/material/table'; |
||||
|
import { ActivatedRoute, RouterModule } from '@angular/router'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
GfBenchmarkComparisonChartComponent, |
||||
|
GfPerformanceMetricsComponent, |
||||
|
MatButtonModule, |
||||
|
MatCardModule, |
||||
|
MatFormFieldModule, |
||||
|
MatIconModule, |
||||
|
MatSelectModule, |
||||
|
MatTableModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
selector: 'gf-partnership-performance-page', |
||||
|
standalone: true, |
||||
|
styles: [ |
||||
|
` |
||||
|
:host { |
||||
|
display: block; |
||||
|
padding: 1rem; |
||||
|
} |
||||
|
|
||||
|
.page-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
margin-bottom: 1rem; |
||||
|
} |
||||
|
|
||||
|
.controls { |
||||
|
display: flex; |
||||
|
gap: 1rem; |
||||
|
margin-bottom: 1.5rem; |
||||
|
flex-wrap: wrap; |
||||
|
} |
||||
|
|
||||
|
.returns-table { |
||||
|
margin-top: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.back-link { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 4px; |
||||
|
text-decoration: none; |
||||
|
color: rgba(var(--dark-primary-text), 0.6); |
||||
|
margin-bottom: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.loading { |
||||
|
text-align: center; |
||||
|
padding: 3rem; |
||||
|
color: rgba(var(--dark-primary-text), 0.5); |
||||
|
} |
||||
|
` |
||||
|
], |
||||
|
template: ` |
||||
|
<a class="back-link" [routerLink]="['/partnerships', partnershipId]"> |
||||
|
<mat-icon>arrow_back</mat-icon> |
||||
|
Back to Partnership |
||||
|
</a> |
||||
|
|
||||
|
<div class="page-header"> |
||||
|
<h1>{{ performance?.partnershipName }} — Performance</h1> |
||||
|
</div> |
||||
|
|
||||
|
<div class="controls"> |
||||
|
<mat-form-field> |
||||
|
<mat-label>Periodicity</mat-label> |
||||
|
<mat-select |
||||
|
[(value)]="periodicity" |
||||
|
(selectionChange)="fetchPerformance()" |
||||
|
> |
||||
|
<mat-option value="MONTHLY">Monthly</mat-option> |
||||
|
<mat-option value="QUARTERLY">Quarterly</mat-option> |
||||
|
<mat-option value="YEARLY">Yearly</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
<mat-form-field> |
||||
|
<mat-label>Benchmarks</mat-label> |
||||
|
<mat-select |
||||
|
multiple |
||||
|
[(value)]="selectedBenchmarks" |
||||
|
(selectionChange)="fetchPerformance()" |
||||
|
> |
||||
|
<mat-option value="SP500">S&P 500</mat-option> |
||||
|
<mat-option value="US_AGG_BOND">US Agg Bond</mat-option> |
||||
|
<mat-option value="REAL_ESTATE">Real Estate</mat-option> |
||||
|
<mat-option value="CPI_PROXY">CPI (TIPS)</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
@if (performance) { |
||||
|
<gf-performance-metrics |
||||
|
[dpi]="performance.metrics.dpi" |
||||
|
[irr]="performance.metrics.irr" |
||||
|
[periodReturns]="periodReturns" |
||||
|
[rvpi]="performance.metrics.rvpi" |
||||
|
[tvpi]="performance.metrics.tvpi" |
||||
|
/> |
||||
|
|
||||
|
@if (performance.periods && performance.periods.length > 0) { |
||||
|
<div class="returns-table"> |
||||
|
<h3>Period Returns Detail</h3> |
||||
|
<table mat-table [dataSource]="performance.periods"> |
||||
|
<ng-container matColumnDef="periodStart"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Period Start</th> |
||||
|
<td *matCellDef="let p" mat-cell> |
||||
|
{{ p.periodStart | date: 'mediumDate' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="periodEnd"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Period End</th> |
||||
|
<td *matCellDef="let p" mat-cell> |
||||
|
{{ p.periodEnd | date: 'mediumDate' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="startNav"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Start NAV</th> |
||||
|
<td *matCellDef="let p" mat-cell> |
||||
|
{{ p.startNav | currency: 'USD' : 'symbol' : '1.0-0' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="endNav"> |
||||
|
<th *matHeaderCellDef mat-header-cell>End NAV</th> |
||||
|
<td *matCellDef="let p" mat-cell> |
||||
|
{{ p.endNav | currency: 'USD' : 'symbol' : '1.0-0' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="returnPercent"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Return</th> |
||||
|
<td |
||||
|
*matCellDef="let p" |
||||
|
mat-cell |
||||
|
[style.color]="p.returnPercent >= 0 ? '#2e7d32' : '#c62828'" |
||||
|
> |
||||
|
{{ p.returnPercent | percent: '1.2-2' }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="returnsColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: returnsColumns" mat-row></tr> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@if ( |
||||
|
performance.benchmarkComparisons && |
||||
|
performance.benchmarkComparisons.length > 0 |
||||
|
) { |
||||
|
<div style="margin-top: 1.5rem;"> |
||||
|
<h3>Benchmark Comparison</h3> |
||||
|
<gf-benchmark-comparison-chart |
||||
|
[benchmarks]="performance.benchmarkComparisons" |
||||
|
[partnershipName]="performance.partnershipName" |
||||
|
[partnershipReturn]="overallReturn" |
||||
|
/> |
||||
|
</div> |
||||
|
} |
||||
|
} @else { |
||||
|
<div class="loading">Loading performance data...</div> |
||||
|
} |
||||
|
` |
||||
|
}) |
||||
|
export class PartnershipPerformancePageComponent implements OnInit { |
||||
|
public overallReturn: number = 0; |
||||
|
public partnershipId: string = ''; |
||||
|
public performance: IPartnershipPerformance | null = null; |
||||
|
public periodicity: string = 'QUARTERLY'; |
||||
|
public periodReturns: { |
||||
|
endDate: string; |
||||
|
return: number; |
||||
|
startDate: string; |
||||
|
}[] = []; |
||||
|
public returnsColumns = [ |
||||
|
'periodStart', |
||||
|
'periodEnd', |
||||
|
'startNav', |
||||
|
'endNav', |
||||
|
'returnPercent' |
||||
|
]; |
||||
|
public selectedBenchmarks: string[] = []; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly activatedRoute: ActivatedRoute, |
||||
|
private readonly changeDetectorRef: ChangeDetectorRef, |
||||
|
private readonly destroyRef: DestroyRef, |
||||
|
private readonly familyOfficeDataService: FamilyOfficeDataService |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit(): void { |
||||
|
this.partnershipId = this.activatedRoute.snapshot.paramMap.get('id') || ''; |
||||
|
this.fetchPerformance(); |
||||
|
} |
||||
|
|
||||
|
public fetchPerformance(): void { |
||||
|
const params: any = { |
||||
|
periodicity: this.periodicity |
||||
|
}; |
||||
|
|
||||
|
if (this.selectedBenchmarks.length > 0) { |
||||
|
params.benchmarks = this.selectedBenchmarks.join(','); |
||||
|
} |
||||
|
|
||||
|
this.familyOfficeDataService |
||||
|
.fetchPartnershipPerformance(this.partnershipId, params) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((perf) => { |
||||
|
this.performance = perf; |
||||
|
this.periodReturns = (perf.periods || []).map((p: any) => ({ |
||||
|
endDate: p.periodEnd, |
||||
|
return: p.returnPercent, |
||||
|
startDate: p.periodStart |
||||
|
})); |
||||
|
this.overallReturn = this.periodReturns.reduce( |
||||
|
(acc, p) => (1 + acc) * (1 + p.return) - 1, |
||||
|
0 |
||||
|
); |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { PartnershipPerformancePageComponent } from './partnership-performance-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: PartnershipPerformancePageComponent, |
||||
|
path: '', |
||||
|
title: 'Partnership Performance' |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,166 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
Inject, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
ReactiveFormsModule, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { |
||||
|
MAT_DIALOG_DATA, |
||||
|
MatDialogModule, |
||||
|
MatDialogRef |
||||
|
} from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
import { CreateOrUpdatePartnershipDialogParams } from './interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-create-or-update-partnership-dialog', |
||||
|
template: ` |
||||
|
<form |
||||
|
class="d-flex flex-column h-100" |
||||
|
[formGroup]="partnershipForm" |
||||
|
(keyup.enter)="partnershipForm.valid && onSubmit()" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
@if (data.partnership.id) { |
||||
|
<h1 i18n mat-dialog-title>Update Partnership</h1> |
||||
|
} @else { |
||||
|
<h1 i18n mat-dialog-title>Add Partnership</h1> |
||||
|
} |
||||
|
|
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Name</mat-label> |
||||
|
<input formControlName="name" matInput required /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Type</mat-label> |
||||
|
<mat-select formControlName="type" required> |
||||
|
<mat-option value="LP">LP</mat-option> |
||||
|
<mat-option value="GP">GP</mat-option> |
||||
|
<mat-option value="LLC">LLC</mat-option> |
||||
|
<mat-option value="JOINT_VENTURE">Joint Venture</mat-option> |
||||
|
<mat-option value="FUND">Fund</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
@if (!data.partnership.id) { |
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Inception Date</mat-label> |
||||
|
<input |
||||
|
formControlName="inceptionDate" |
||||
|
matInput |
||||
|
required |
||||
|
type="date" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Currency</mat-label> |
||||
|
<input formControlName="currency" matInput required /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Fiscal Year End (Month)</mat-label> |
||||
|
<mat-select formControlName="fiscalYearEnd"> |
||||
|
@for (m of months; track m.value) { |
||||
|
<mat-option [value]="m.value">{{ m.label }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button type="button" (click)="onCancel()"> |
||||
|
{{ partnershipForm.dirty ? 'Cancel' : 'Close' }} |
||||
|
</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="!(partnershipForm.dirty && partnershipForm.valid)" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
` |
||||
|
}) |
||||
|
export class GfCreateOrUpdatePartnershipDialogComponent implements OnInit { |
||||
|
public months = [ |
||||
|
{ value: 1, label: 'January' }, |
||||
|
{ value: 2, label: 'February' }, |
||||
|
{ value: 3, label: 'March' }, |
||||
|
{ value: 4, label: 'April' }, |
||||
|
{ value: 5, label: 'May' }, |
||||
|
{ value: 6, label: 'June' }, |
||||
|
{ value: 7, label: 'July' }, |
||||
|
{ value: 8, label: 'August' }, |
||||
|
{ value: 9, label: 'September' }, |
||||
|
{ value: 10, label: 'October' }, |
||||
|
{ value: 11, label: 'November' }, |
||||
|
{ value: 12, label: 'December' } |
||||
|
]; |
||||
|
public partnershipForm: FormGroup; |
||||
|
|
||||
|
public constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) |
||||
|
public data: CreateOrUpdatePartnershipDialogParams, |
||||
|
public dialogRef: MatDialogRef<GfCreateOrUpdatePartnershipDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.partnershipForm = new FormGroup({ |
||||
|
name: new FormControl(this.data.partnership.name, [Validators.required]), |
||||
|
type: new FormControl(this.data.partnership.type, [Validators.required]), |
||||
|
currency: new FormControl(this.data.partnership.currency, [ |
||||
|
Validators.required |
||||
|
]), |
||||
|
inceptionDate: new FormControl(this.data.partnership.inceptionDate), |
||||
|
fiscalYearEnd: new FormControl(this.data.partnership.fiscalYearEnd) |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
if (this.partnershipForm.valid) { |
||||
|
this.dialogRef.close(this.partnershipForm.value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
export interface CreateOrUpdatePartnershipDialogParams { |
||||
|
partnership: { |
||||
|
id: string | null; |
||||
|
name: string; |
||||
|
type: string; |
||||
|
currency: string; |
||||
|
inceptionDate: string; |
||||
|
fiscalYearEnd: number; |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
import { CreatePartnershipDto } from '@ghostfolio/common/dtos'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { Component } from '@angular/core'; |
||||
|
import { FormsModule } from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
@Component({ |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatSelectModule |
||||
|
], |
||||
|
selector: 'gf-create-partnership-dialog', |
||||
|
standalone: true, |
||||
|
template: ` |
||||
|
<h2 mat-dialog-title>Create Partnership</h2> |
||||
|
<mat-dialog-content> |
||||
|
<mat-form-field appearance="outline" style="width: 100%"> |
||||
|
<mat-label>Name</mat-label> |
||||
|
<input matInput required [(ngModel)]="name" /> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" style="width: 100%"> |
||||
|
<mat-label>Type</mat-label> |
||||
|
<mat-select required [(ngModel)]="type"> |
||||
|
<mat-option value="LP">LP</mat-option> |
||||
|
<mat-option value="GP">GP</mat-option> |
||||
|
<mat-option value="LLC">LLC</mat-option> |
||||
|
<mat-option value="JOINT_VENTURE">Joint Venture</mat-option> |
||||
|
<mat-option value="FUND">Fund</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" style="width: 100%"> |
||||
|
<mat-label>Inception Date</mat-label> |
||||
|
<input matInput required type="date" [(ngModel)]="inceptionDate" /> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" style="width: 100%"> |
||||
|
<mat-label>Currency</mat-label> |
||||
|
<input matInput placeholder="USD" required [(ngModel)]="currency" /> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline" style="width: 100%"> |
||||
|
<mat-label>Fiscal Year End (Month)</mat-label> |
||||
|
<mat-select [(ngModel)]="fiscalYearEnd"> |
||||
|
<mat-option [value]="1">January</mat-option> |
||||
|
<mat-option [value]="2">February</mat-option> |
||||
|
<mat-option [value]="3">March</mat-option> |
||||
|
<mat-option [value]="4">April</mat-option> |
||||
|
<mat-option [value]="5">May</mat-option> |
||||
|
<mat-option [value]="6">June</mat-option> |
||||
|
<mat-option [value]="7">July</mat-option> |
||||
|
<mat-option [value]="8">August</mat-option> |
||||
|
<mat-option [value]="9">September</mat-option> |
||||
|
<mat-option [value]="10">October</mat-option> |
||||
|
<mat-option [value]="11">November</mat-option> |
||||
|
<mat-option [value]="12">December</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</mat-dialog-content> |
||||
|
<mat-dialog-actions align="end"> |
||||
|
<button mat-button mat-dialog-close>Cancel</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
[disabled]="!name || !type || !inceptionDate || !currency" |
||||
|
(click)="onSave()" |
||||
|
> |
||||
|
Create |
||||
|
</button> |
||||
|
</mat-dialog-actions> |
||||
|
` |
||||
|
}) |
||||
|
export class GfCreatePartnershipDialogComponent { |
||||
|
public currency = 'USD'; |
||||
|
public fiscalYearEnd = 12; |
||||
|
public inceptionDate = ''; |
||||
|
public name = ''; |
||||
|
public type = ''; |
||||
|
|
||||
|
public constructor( |
||||
|
private dialogRef: MatDialogRef<GfCreatePartnershipDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public onSave() { |
||||
|
const dto: CreatePartnershipDto = { |
||||
|
name: this.name, |
||||
|
type: this.type as any, |
||||
|
inceptionDate: this.inceptionDate, |
||||
|
currency: this.currency, |
||||
|
fiscalYearEnd: this.fiscalYearEnd |
||||
|
}; |
||||
|
|
||||
|
this.dialogRef.close(dto); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,250 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { User } from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { NotificationService } from '@ghostfolio/ui/notifications'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit, |
||||
|
ViewChild |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { MatMenuModule } from '@angular/material/menu'; |
||||
|
import { MatSort, MatSortModule } from '@angular/material/sort'; |
||||
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; |
||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { addOutline, ellipsisVerticalOutline } from 'ionicons/icons'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
||||
|
|
||||
|
import { GfCreateOrUpdatePartnershipDialogComponent } from './create-or-update-partnership-dialog/create-or-update-partnership-dialog.component'; |
||||
|
import { CreateOrUpdatePartnershipDialogParams } from './create-or-update-partnership-dialog/interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
host: { class: 'has-fab page' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatMenuModule, |
||||
|
MatSortModule, |
||||
|
MatTableModule, |
||||
|
NgxSkeletonLoaderModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
selector: 'gf-partnerships-page', |
||||
|
styleUrls: ['./partnerships-page.scss'], |
||||
|
templateUrl: './partnerships-page.html' |
||||
|
}) |
||||
|
export class GfPartnershipsPageComponent implements OnInit { |
||||
|
public dataSource = new MatTableDataSource<any>([]); |
||||
|
public deviceType: string; |
||||
|
public displayedColumns = [ |
||||
|
'name', |
||||
|
'type', |
||||
|
'currency', |
||||
|
'latestNav', |
||||
|
'membersCount', |
||||
|
'actions' |
||||
|
]; |
||||
|
public hasPermissionToCreate = false; |
||||
|
public hasPermissionToUpdate = false; |
||||
|
public hasPermissionToDelete = false; |
||||
|
public isLoading = true; |
||||
|
public partnerships: any[]; |
||||
|
public showActions = false; |
||||
|
public user: User; |
||||
|
|
||||
|
@ViewChild(MatSort) sort: MatSort; |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private deviceService: DeviceDetectorService, |
||||
|
private dialog: MatDialog, |
||||
|
private familyOfficeDataService: FamilyOfficeDataService, |
||||
|
private notificationService: NotificationService, |
||||
|
private route: ActivatedRoute, |
||||
|
private router: Router, |
||||
|
private userService: UserService |
||||
|
) { |
||||
|
addIcons({ addOutline, ellipsisVerticalOutline }); |
||||
|
|
||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
||||
|
|
||||
|
this.route.queryParams |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((params) => { |
||||
|
if (params['createDialog']) { |
||||
|
this.openCreatePartnershipDialog(); |
||||
|
} else if (params['editDialog']) { |
||||
|
const partnership = this.partnerships?.find( |
||||
|
(p) => p.id === params['partnershipId'] |
||||
|
); |
||||
|
if (partnership) { |
||||
|
this.openUpdatePartnershipDialog(partnership); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
this.hasPermissionToCreate = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.createPartnership |
||||
|
); |
||||
|
this.hasPermissionToUpdate = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.updatePartnership |
||||
|
); |
||||
|
this.hasPermissionToDelete = hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.deletePartnership |
||||
|
); |
||||
|
this.showActions = |
||||
|
this.hasPermissionToUpdate || this.hasPermissionToDelete; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.fetchPartnerships(); |
||||
|
} |
||||
|
|
||||
|
public fetchPartnerships() { |
||||
|
this.isLoading = true; |
||||
|
this.familyOfficeDataService |
||||
|
.fetchPartnerships() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((partnerships) => { |
||||
|
this.partnerships = partnerships; |
||||
|
this.dataSource.data = partnerships; |
||||
|
this.dataSource.sort = this.sort; |
||||
|
this.isLoading = false; |
||||
|
|
||||
|
if (this.partnerships?.length <= 0) { |
||||
|
this.router.navigate([], { |
||||
|
queryParams: { createDialog: true } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onDeletePartnership(partnershipId: string) { |
||||
|
this.notificationService.confirm({ |
||||
|
confirmFn: () => { |
||||
|
this.familyOfficeDataService |
||||
|
.deletePartnership(partnershipId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.reset(); |
||||
|
this.fetchPartnerships(); |
||||
|
}); |
||||
|
}, |
||||
|
confirmType: undefined, |
||||
|
message: $localize`Do you really want to delete this partnership?`, |
||||
|
title: $localize`Delete Partnership` |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onPartnershipClicked(partnership: any) { |
||||
|
this.router.navigate(['/partnerships', partnership.id]); |
||||
|
} |
||||
|
|
||||
|
public onUpdatePartnership(partnership: any) { |
||||
|
this.router.navigate([], { |
||||
|
queryParams: { partnershipId: partnership.id, editDialog: true } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openCreatePartnershipDialog() { |
||||
|
const dialogRef = this.dialog.open( |
||||
|
GfCreateOrUpdatePartnershipDialogComponent, |
||||
|
{ |
||||
|
data: { |
||||
|
partnership: { |
||||
|
id: null, |
||||
|
name: '', |
||||
|
type: 'LP', |
||||
|
currency: 'USD', |
||||
|
inceptionDate: new Date().toISOString().split('T')[0], |
||||
|
fiscalYearEnd: 12 |
||||
|
} |
||||
|
} as CreateOrUpdatePartnershipDialogParams, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
this.router.navigate(['.'], { relativeTo: this.route }); |
||||
|
|
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.createPartnership(result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.reset(); |
||||
|
this.fetchPartnerships(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openUpdatePartnershipDialog(partnership: any) { |
||||
|
const dialogRef = this.dialog.open( |
||||
|
GfCreateOrUpdatePartnershipDialogComponent, |
||||
|
{ |
||||
|
data: { |
||||
|
partnership: { |
||||
|
id: partnership.id, |
||||
|
name: partnership.name, |
||||
|
type: partnership.type, |
||||
|
currency: partnership.currency, |
||||
|
inceptionDate: partnership.inceptionDate ?? '', |
||||
|
fiscalYearEnd: partnership.fiscalYearEnd ?? 12 |
||||
|
} |
||||
|
} as CreateOrUpdatePartnershipDialogParams, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
this.router.navigate(['.'], { relativeTo: this.route }); |
||||
|
|
||||
|
if (result) { |
||||
|
this.familyOfficeDataService |
||||
|
.updatePartnership(partnership.id, result) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.reset(); |
||||
|
this.fetchPartnerships(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private reset() { |
||||
|
this.partnerships = undefined; |
||||
|
this.dataSource.data = []; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,124 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Partnerships</h1> |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
matSort |
||||
|
matSortActive="name" |
||||
|
matSortDirection="asc" |
||||
|
[dataSource]="dataSource" |
||||
|
> |
||||
|
<ng-container matColumnDef="name"> |
||||
|
<th *matHeaderCellDef mat-header-cell mat-sort-header>Name</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.name }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="type"> |
||||
|
<th *matHeaderCellDef mat-header-cell mat-sort-header>Type</th> |
||||
|
<td *matCellDef="let row" mat-cell>{{ row.type }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="currency"> |
||||
|
<th *matHeaderCellDef class="d-none d-lg-table-cell" mat-header-cell> |
||||
|
Currency |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="d-none d-lg-table-cell" mat-cell> |
||||
|
{{ row.currency }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="latestNav"> |
||||
|
<th *matHeaderCellDef class="text-right" mat-header-cell> |
||||
|
Latest NAV |
||||
|
</th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
{{ |
||||
|
row.latestNav != null |
||||
|
? (row.latestNav | currency: row.currency : 'symbol' : '1.0-0') |
||||
|
: '—' |
||||
|
}} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="membersCount"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-header-cell |
||||
|
> |
||||
|
Members |
||||
|
</th> |
||||
|
<td |
||||
|
*matCellDef="let row" |
||||
|
class="d-none d-lg-table-cell text-right" |
||||
|
mat-cell |
||||
|
> |
||||
|
{{ row.membersCount ?? 0 }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="actions" stickyEnd> |
||||
|
<th *matHeaderCellDef mat-header-cell></th> |
||||
|
<td *matCellDef="let row" class="text-right" mat-cell> |
||||
|
@if (showActions) { |
||||
|
<button |
||||
|
mat-icon-button |
||||
|
[matMenuTriggerFor]="partnershipMenu" |
||||
|
(click)="$event.stopPropagation()" |
||||
|
> |
||||
|
<ion-icon name="ellipsis-vertical-outline" /> |
||||
|
</button> |
||||
|
<mat-menu #partnershipMenu="matMenu"> |
||||
|
<button mat-menu-item (click)="onUpdatePartnership(row)"> |
||||
|
Edit |
||||
|
</button> |
||||
|
<button mat-menu-item (click)="onDeletePartnership(row.id)"> |
||||
|
Delete |
||||
|
</button> |
||||
|
</mat-menu> |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
||||
|
<tr |
||||
|
*matRowDef="let row; columns: displayedColumns" |
||||
|
class="cursor-pointer" |
||||
|
mat-row |
||||
|
(click)="onPartnershipClicked(row)" |
||||
|
></tr> |
||||
|
</table> |
||||
|
|
||||
|
@if (isLoading) { |
||||
|
<ngx-skeleton-loader |
||||
|
animation="pulse" |
||||
|
[theme]="{ height: '3rem', width: '100%' }" |
||||
|
/> |
||||
|
} |
||||
|
|
||||
|
@if (!isLoading && partnerships?.length === 0) { |
||||
|
<div class="p-3 text-center" style="opacity: 0.6"> |
||||
|
<p i18n> |
||||
|
No partnerships yet. Create your first partnership to get started. |
||||
|
</p> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
@if (hasPermissionToCreate) { |
||||
|
<div class="fab-container"> |
||||
|
<a |
||||
|
class="align-items-center d-flex justify-content-center" |
||||
|
color="primary" |
||||
|
mat-fab |
||||
|
[queryParams]="{ createDialog: true }" |
||||
|
[routerLink]="[]" |
||||
|
> |
||||
|
<ion-icon name="add-outline" size="large" /> |
||||
|
</a> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { GfPartnershipsPageComponent } from './partnerships-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: GfPartnershipsPageComponent, |
||||
|
path: '', |
||||
|
title: 'Partnerships' |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,3 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
@ -0,0 +1,510 @@ |
|||||
|
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; |
||||
|
import type { |
||||
|
IEntity, |
||||
|
IFamilyOfficeReport |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { GfBenchmarkComparisonChartComponent } from '@ghostfolio/ui/benchmark-comparison-chart'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { FormsModule } from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatCardModule } from '@angular/material/card'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatIconModule } from '@angular/material/icon'; |
||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
import { MatTableModule } from '@angular/material/table'; |
||||
|
import { RouterModule } from '@angular/router'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
GfBenchmarkComparisonChartComponent, |
||||
|
MatButtonModule, |
||||
|
MatCardModule, |
||||
|
MatFormFieldModule, |
||||
|
MatIconModule, |
||||
|
MatProgressSpinnerModule, |
||||
|
MatSelectModule, |
||||
|
MatTableModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
selector: 'gf-report-page', |
||||
|
standalone: true, |
||||
|
styles: [ |
||||
|
` |
||||
|
:host { |
||||
|
display: block; |
||||
|
padding: 1rem; |
||||
|
} |
||||
|
|
||||
|
.page-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.filter-row { |
||||
|
display: flex; |
||||
|
gap: 1rem; |
||||
|
flex-wrap: wrap; |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.filter-row mat-form-field { |
||||
|
min-width: 160px; |
||||
|
} |
||||
|
|
||||
|
.summary-cards { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
||||
|
gap: 1rem; |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.summary-card { |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
.summary-card .value { |
||||
|
font-size: 1.5rem; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.summary-card .label { |
||||
|
color: rgba(0, 0, 0, 0.6); |
||||
|
font-size: 0.85rem; |
||||
|
margin-top: 0.25rem; |
||||
|
} |
||||
|
|
||||
|
.positive { |
||||
|
color: #4caf50; |
||||
|
} |
||||
|
|
||||
|
.negative { |
||||
|
color: #f44336; |
||||
|
} |
||||
|
|
||||
|
.section { |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.section h3 { |
||||
|
margin-bottom: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar { |
||||
|
display: flex; |
||||
|
gap: 0.5rem; |
||||
|
margin-bottom: 0.5rem; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar .bar { |
||||
|
height: 20px; |
||||
|
background-color: #1976d2; |
||||
|
border-radius: 4px; |
||||
|
min-width: 4px; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar .label { |
||||
|
min-width: 140px; |
||||
|
font-size: 0.85rem; |
||||
|
} |
||||
|
|
||||
|
.allocation-bar .pct { |
||||
|
font-size: 0.85rem; |
||||
|
min-width: 50px; |
||||
|
text-align: right; |
||||
|
} |
||||
|
|
||||
|
.loading-container { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
padding: 3rem; |
||||
|
} |
||||
|
` |
||||
|
], |
||||
|
template: ` |
||||
|
<div class="page-header"> |
||||
|
<h1>Performance Report</h1> |
||||
|
</div> |
||||
|
|
||||
|
<div class="filter-row"> |
||||
|
<mat-form-field appearance="outline"> |
||||
|
<mat-label>Period</mat-label> |
||||
|
<mat-select [(value)]="selectedPeriod"> |
||||
|
<mat-option value="MONTHLY">Monthly</mat-option> |
||||
|
<mat-option value="QUARTERLY">Quarterly</mat-option> |
||||
|
<mat-option value="YEARLY">Yearly</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline"> |
||||
|
<mat-label>Year</mat-label> |
||||
|
<mat-select [(value)]="selectedYear"> |
||||
|
@for (y of availableYears; track y) { |
||||
|
<mat-option [value]="y">{{ y }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
@if (selectedPeriod === 'MONTHLY') { |
||||
|
<mat-form-field appearance="outline"> |
||||
|
<mat-label>Month</mat-label> |
||||
|
<mat-select [(value)]="selectedPeriodNumber"> |
||||
|
@for (m of months; track m.value) { |
||||
|
<mat-option [value]="m.value">{{ m.label }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
} |
||||
|
|
||||
|
@if (selectedPeriod === 'QUARTERLY') { |
||||
|
<mat-form-field appearance="outline"> |
||||
|
<mat-label>Quarter</mat-label> |
||||
|
<mat-select [(value)]="selectedPeriodNumber"> |
||||
|
<mat-option [value]="1">Q1</mat-option> |
||||
|
<mat-option [value]="2">Q2</mat-option> |
||||
|
<mat-option [value]="3">Q3</mat-option> |
||||
|
<mat-option [value]="4">Q4</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
} |
||||
|
|
||||
|
<mat-form-field appearance="outline"> |
||||
|
<mat-label>Entity (optional)</mat-label> |
||||
|
<mat-select [(value)]="selectedEntityId"> |
||||
|
<mat-option [value]="null">Family-wide</mat-option> |
||||
|
@for (entity of entities; track entity.id) { |
||||
|
<mat-option [value]="entity.id">{{ entity.name }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline"> |
||||
|
<mat-label>Benchmarks</mat-label> |
||||
|
<mat-select multiple [(value)]="selectedBenchmarks"> |
||||
|
<mat-option value="SP500">S&P 500</mat-option> |
||||
|
<mat-option value="US_AGG_BOND">US Agg Bond</mat-option> |
||||
|
<mat-option value="REAL_ESTATE">Real Estate</mat-option> |
||||
|
<mat-option value="CPI_PROXY">CPI Proxy</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-raised-button |
||||
|
[disabled]="isLoading" |
||||
|
(click)="generateReport()" |
||||
|
> |
||||
|
Generate Report |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
@if (isLoading) { |
||||
|
<div class="loading-container"> |
||||
|
<mat-spinner diameter="48"></mat-spinner> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@if (report) { |
||||
|
<mat-card class="section"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>{{ report.reportTitle }}</mat-card-title> |
||||
|
<mat-card-subtitle> |
||||
|
{{ report.period.start }} — {{ report.period.end }} |
||||
|
@if (report.entity) { |
||||
|
· {{ report.entity.name }} |
||||
|
} |
||||
|
</mat-card-subtitle> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<div class="summary-cards"> |
||||
|
<div class="summary-card"> |
||||
|
<div class="value"> |
||||
|
{{ report.summary.totalValueStart | number: '1.0-0' }} |
||||
|
</div> |
||||
|
<div class="label">Start Value</div> |
||||
|
</div> |
||||
|
<div class="summary-card"> |
||||
|
<div class="value"> |
||||
|
{{ report.summary.totalValueEnd | number: '1.0-0' }} |
||||
|
</div> |
||||
|
<div class="label">End Value</div> |
||||
|
</div> |
||||
|
<div class="summary-card"> |
||||
|
<div |
||||
|
class="value" |
||||
|
[class.negative]="report.summary.periodChange < 0" |
||||
|
[class.positive]="report.summary.periodChange > 0" |
||||
|
> |
||||
|
{{ report.summary.periodChange | number: '1.0-0' }} |
||||
|
({{ |
||||
|
report.summary.periodChangePercent * 100 | number: '1.2-2' |
||||
|
}}%) |
||||
|
</div> |
||||
|
<div class="label">Period Change</div> |
||||
|
</div> |
||||
|
<div class="summary-card"> |
||||
|
<div |
||||
|
class="value" |
||||
|
[class.negative]="report.summary.ytdChangePercent < 0" |
||||
|
[class.positive]="report.summary.ytdChangePercent > 0" |
||||
|
> |
||||
|
{{ report.summary.ytdChangePercent * 100 | number: '1.2-2' }}% |
||||
|
</div> |
||||
|
<div class="label">YTD Change</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<!-- Asset Allocation --> |
||||
|
@if (assetAllocationEntries.length > 0) { |
||||
|
<mat-card class="section"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>Asset Allocation</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
@for (entry of assetAllocationEntries; track entry.name) { |
||||
|
<div class="allocation-bar"> |
||||
|
<span class="label">{{ entry.name }}</span> |
||||
|
<div class="bar" [style.width.%]="entry.percentage"></div> |
||||
|
<span class="pct" |
||||
|
>{{ entry.percentage | number: '1.1-1' }}%</span |
||||
|
> |
||||
|
</div> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
} |
||||
|
|
||||
|
<!-- Partnership Performance --> |
||||
|
@if (report.partnershipPerformance.length > 0) { |
||||
|
<mat-card class="section"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>Partnership Performance</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<table |
||||
|
class="mat-elevation-z0" |
||||
|
mat-table |
||||
|
[dataSource]="report.partnershipPerformance" |
||||
|
> |
||||
|
<ng-container matColumnDef="name"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Partnership</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.partnershipName }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="periodReturn"> |
||||
|
<th *matHeaderCellDef mat-header-cell>Period Return</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.periodReturn * 100 | number: '1.2-2' }}% |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="irr"> |
||||
|
<th *matHeaderCellDef mat-header-cell>IRR</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.irr * 100 | number: '1.2-2' }}% |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="tvpi"> |
||||
|
<th *matHeaderCellDef mat-header-cell>TVPI</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.tvpi | number: '1.2-2' }}x |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="dpi"> |
||||
|
<th *matHeaderCellDef mat-header-cell>DPI</th> |
||||
|
<td *matCellDef="let row" mat-cell> |
||||
|
{{ row.dpi | number: '1.2-2' }}x |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
<tr *matHeaderRowDef="partnershipColumns" mat-header-row></tr> |
||||
|
<tr |
||||
|
*matRowDef="let row; columns: partnershipColumns" |
||||
|
mat-row |
||||
|
></tr> |
||||
|
</table> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
} |
||||
|
|
||||
|
<!-- Distribution Summary --> |
||||
|
@if (report.distributionSummary.periodTotal > 0) { |
||||
|
<mat-card class="section"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>Distribution Summary</mat-card-title> |
||||
|
<mat-card-subtitle> |
||||
|
Period Total: |
||||
|
{{ report.distributionSummary.periodTotal | number: '1.0-0' }} |
||||
|
</mat-card-subtitle> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
@for (entry of distributionTypeEntries; track entry.type) { |
||||
|
<div class="allocation-bar"> |
||||
|
<span class="label">{{ entry.type }}</span> |
||||
|
<div |
||||
|
class="bar" |
||||
|
style="background-color: #ff9800" |
||||
|
[style.width.%]="entry.percentage" |
||||
|
></div> |
||||
|
<span class="pct">{{ entry.amount | number: '1.0-0' }}</span> |
||||
|
</div> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
} |
||||
|
|
||||
|
<!-- Benchmark Comparisons --> |
||||
|
@if ( |
||||
|
report.benchmarkComparisons && report.benchmarkComparisons.length > 0 |
||||
|
) { |
||||
|
<mat-card class="section"> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title>Benchmark Comparisons</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-benchmark-comparison-chart |
||||
|
[benchmarkComparisons]="report.benchmarkComparisons" |
||||
|
[overallReturn]="report.summary.periodChangePercent" |
||||
|
[partnershipName]="report.entity?.name ?? 'Family Portfolio'" |
||||
|
></gf-benchmark-comparison-chart> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
} |
||||
|
} |
||||
|
` |
||||
|
}) |
||||
|
export class ReportPageComponent implements OnInit { |
||||
|
public assetAllocationEntries: { |
||||
|
name: string; |
||||
|
percentage: number; |
||||
|
value: number; |
||||
|
}[] = []; |
||||
|
public availableYears: number[] = []; |
||||
|
public distributionTypeEntries: { |
||||
|
amount: number; |
||||
|
percentage: number; |
||||
|
type: string; |
||||
|
}[] = []; |
||||
|
public entities: IEntity[] = []; |
||||
|
public isLoading = false; |
||||
|
public months = [ |
||||
|
{ label: 'January', value: 1 }, |
||||
|
{ label: 'February', value: 2 }, |
||||
|
{ label: 'March', value: 3 }, |
||||
|
{ label: 'April', value: 4 }, |
||||
|
{ label: 'May', value: 5 }, |
||||
|
{ label: 'June', value: 6 }, |
||||
|
{ label: 'July', value: 7 }, |
||||
|
{ label: 'August', value: 8 }, |
||||
|
{ label: 'September', value: 9 }, |
||||
|
{ label: 'October', value: 10 }, |
||||
|
{ label: 'November', value: 11 }, |
||||
|
{ label: 'December', value: 12 } |
||||
|
]; |
||||
|
public partnershipColumns = ['name', 'periodReturn', 'irr', 'tvpi', 'dpi']; |
||||
|
public report: IFamilyOfficeReport | null = null; |
||||
|
public selectedBenchmarks: string[] = []; |
||||
|
public selectedEntityId: string | null = null; |
||||
|
public selectedPeriod = 'QUARTERLY'; |
||||
|
public selectedPeriodNumber = Math.ceil((new Date().getMonth() + 1) / 3); |
||||
|
public selectedYear = new Date().getFullYear(); |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly changeDetectorRef: ChangeDetectorRef, |
||||
|
private readonly destroyRef: DestroyRef, |
||||
|
private readonly familyOfficeDataService: FamilyOfficeDataService |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
const currentYear = new Date().getFullYear(); |
||||
|
this.availableYears = []; |
||||
|
|
||||
|
for (let y = currentYear; y >= currentYear - 5; y--) { |
||||
|
this.availableYears.push(y); |
||||
|
} |
||||
|
|
||||
|
// Load entities for the dropdown
|
||||
|
this.familyOfficeDataService |
||||
|
.fetchEntities() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((entities) => { |
||||
|
this.entities = entities; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public generateReport() { |
||||
|
this.isLoading = true; |
||||
|
this.report = null; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
|
||||
|
this.familyOfficeDataService |
||||
|
.fetchReport({ |
||||
|
benchmarks: |
||||
|
this.selectedBenchmarks.length > 0 |
||||
|
? this.selectedBenchmarks.join(',') |
||||
|
: undefined, |
||||
|
entityId: this.selectedEntityId ?? undefined, |
||||
|
period: this.selectedPeriod as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', |
||||
|
periodNumber: |
||||
|
this.selectedPeriod !== 'YEARLY' |
||||
|
? this.selectedPeriodNumber |
||||
|
: undefined, |
||||
|
year: this.selectedYear |
||||
|
}) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe({ |
||||
|
error: () => { |
||||
|
this.isLoading = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}, |
||||
|
next: (report) => { |
||||
|
this.report = report; |
||||
|
this.isLoading = false; |
||||
|
this.processReportData(); |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private processReportData() { |
||||
|
if (!this.report) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Process asset allocation
|
||||
|
this.assetAllocationEntries = Object.entries( |
||||
|
this.report.assetAllocation |
||||
|
).map(([name, data]) => ({ |
||||
|
name, |
||||
|
percentage: data.percentage, |
||||
|
value: data.value |
||||
|
})); |
||||
|
|
||||
|
// Process distribution types
|
||||
|
const total = this.report.distributionSummary.periodTotal || 1; |
||||
|
this.distributionTypeEntries = Object.entries( |
||||
|
this.report.distributionSummary.byType |
||||
|
).map(([type, amount]) => ({ |
||||
|
amount, |
||||
|
percentage: (amount / total) * 100, |
||||
|
type |
||||
|
})); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { ReportPageComponent } from './report-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: ReportPageComponent, |
||||
|
path: '', |
||||
|
title: $localize`Reports` |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,392 @@ |
|||||
|
import { |
||||
|
IDistributionListResponse, |
||||
|
IEntity, |
||||
|
IEntityPortfolio, |
||||
|
IEntityWithRelations, |
||||
|
IFamilyOfficeDashboard, |
||||
|
IFamilyOfficeReport, |
||||
|
IKDocument, |
||||
|
IPartnership, |
||||
|
IPartnershipDetail, |
||||
|
IPartnershipPerformance, |
||||
|
IPartnershipValuation |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { HttpClient, HttpParams } from '@angular/common/http'; |
||||
|
import { Injectable } from '@angular/core'; |
||||
|
import { Observable } from 'rxjs'; |
||||
|
|
||||
|
@Injectable({ |
||||
|
providedIn: 'root' |
||||
|
}) |
||||
|
export class FamilyOfficeDataService { |
||||
|
public constructor(private http: HttpClient) {} |
||||
|
|
||||
|
// Entity endpoints
|
||||
|
public createEntity(data: { |
||||
|
name: string; |
||||
|
type: string; |
||||
|
taxId?: string; |
||||
|
}): Observable<IEntity> { |
||||
|
return this.http.post<IEntity>('/api/v1/entity', data); |
||||
|
} |
||||
|
|
||||
|
public fetchEntities(params?: { type?: string }): Observable<IEntity[]> { |
||||
|
let httpParams = new HttpParams(); |
||||
|
if (params?.type) { |
||||
|
httpParams = httpParams.set('type', params.type); |
||||
|
} |
||||
|
return this.http.get<IEntity[]>('/api/v1/entity', { params: httpParams }); |
||||
|
} |
||||
|
|
||||
|
public fetchEntity(entityId: string): Observable<IEntityWithRelations> { |
||||
|
return this.http.get<IEntityWithRelations>(`/api/v1/entity/${entityId}`); |
||||
|
} |
||||
|
|
||||
|
public updateEntity( |
||||
|
entityId: string, |
||||
|
data: { name?: string; taxId?: string } |
||||
|
): Observable<IEntity> { |
||||
|
return this.http.put<IEntity>(`/api/v1/entity/${entityId}`, data); |
||||
|
} |
||||
|
|
||||
|
public deleteEntity(entityId: string): Observable<void> { |
||||
|
return this.http.delete<void>(`/api/v1/entity/${entityId}`); |
||||
|
} |
||||
|
|
||||
|
public fetchEntityPortfolio( |
||||
|
entityId: string, |
||||
|
params?: { dateRange?: string } |
||||
|
): Observable<IEntityPortfolio> { |
||||
|
let httpParams = new HttpParams(); |
||||
|
if (params?.dateRange) { |
||||
|
httpParams = httpParams.set('dateRange', params.dateRange); |
||||
|
} |
||||
|
return this.http.get<IEntityPortfolio>( |
||||
|
`/api/v1/entity/${entityId}/portfolio`, |
||||
|
{ params: httpParams } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public createOwnership( |
||||
|
entityId: string, |
||||
|
data: { |
||||
|
accountId: string; |
||||
|
ownershipPercent: number; |
||||
|
effectiveDate: string; |
||||
|
acquisitionDate?: string; |
||||
|
costBasis?: number; |
||||
|
} |
||||
|
): Observable<any> { |
||||
|
return this.http.post(`/api/v1/entity/${entityId}/ownership`, data); |
||||
|
} |
||||
|
|
||||
|
public fetchEntityDistributions( |
||||
|
entityId: string, |
||||
|
params?: { startDate?: string; endDate?: string; groupBy?: string } |
||||
|
): Observable<IDistributionListResponse> { |
||||
|
let httpParams = new HttpParams(); |
||||
|
if (params?.startDate) { |
||||
|
httpParams = httpParams.set('startDate', params.startDate); |
||||
|
} |
||||
|
if (params?.endDate) { |
||||
|
httpParams = httpParams.set('endDate', params.endDate); |
||||
|
} |
||||
|
if (params?.groupBy) { |
||||
|
httpParams = httpParams.set('groupBy', params.groupBy); |
||||
|
} |
||||
|
return this.http.get<IDistributionListResponse>( |
||||
|
`/api/v1/entity/${entityId}/distributions`, |
||||
|
{ params: httpParams } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public fetchEntityKDocuments( |
||||
|
entityId: string, |
||||
|
params?: { taxYear?: number } |
||||
|
): Observable<any[]> { |
||||
|
let httpParams = new HttpParams(); |
||||
|
if (params?.taxYear) { |
||||
|
httpParams = httpParams.set('taxYear', params.taxYear.toString()); |
||||
|
} |
||||
|
return this.http.get<any[]>(`/api/v1/entity/${entityId}/k-documents`, { |
||||
|
params: httpParams |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Partnership endpoints
|
||||
|
public createPartnership(data: { |
||||
|
name: string; |
||||
|
type: string; |
||||
|
inceptionDate: string; |
||||
|
fiscalYearEnd?: number; |
||||
|
currency: string; |
||||
|
}): Observable<IPartnership> { |
||||
|
return this.http.post<IPartnership>('/api/v1/partnership', data); |
||||
|
} |
||||
|
|
||||
|
public fetchPartnerships(): Observable<IPartnership[]> { |
||||
|
return this.http.get<IPartnership[]>('/api/v1/partnership'); |
||||
|
} |
||||
|
|
||||
|
public fetchPartnership( |
||||
|
partnershipId: string |
||||
|
): Observable<IPartnershipDetail> { |
||||
|
return this.http.get<IPartnershipDetail>( |
||||
|
`/api/v1/partnership/${partnershipId}` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public updatePartnership( |
||||
|
partnershipId: string, |
||||
|
data: { name?: string; fiscalYearEnd?: number } |
||||
|
): Observable<IPartnership> { |
||||
|
return this.http.put<IPartnership>( |
||||
|
`/api/v1/partnership/${partnershipId}`, |
||||
|
data |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public deletePartnership(partnershipId: string): Observable<void> { |
||||
|
return this.http.delete<void>(`/api/v1/partnership/${partnershipId}`); |
||||
|
} |
||||
|
|
||||
|
public addPartnershipMember( |
||||
|
partnershipId: string, |
||||
|
data: { |
||||
|
entityId: string; |
||||
|
ownershipPercent: number; |
||||
|
capitalCommitment?: number; |
||||
|
capitalContributed?: number; |
||||
|
classType?: string; |
||||
|
effectiveDate: string; |
||||
|
} |
||||
|
): Observable<any> { |
||||
|
return this.http.post(`/api/v1/partnership/${partnershipId}/member`, data); |
||||
|
} |
||||
|
|
||||
|
public updatePartnershipMember( |
||||
|
partnershipId: string, |
||||
|
membershipId: string, |
||||
|
data: Record<string, unknown> |
||||
|
): Observable<any> { |
||||
|
return this.http.put( |
||||
|
`/api/v1/partnership/${partnershipId}/member/${membershipId}`, |
||||
|
data |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public createPartnershipValuation( |
||||
|
partnershipId: string, |
||||
|
data: { date: string; nav: number; source: string; notes?: string } |
||||
|
): Observable<IPartnershipValuation> { |
||||
|
return this.http.post<IPartnershipValuation>( |
||||
|
`/api/v1/partnership/${partnershipId}/valuation`, |
||||
|
data |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public fetchPartnershipValuations( |
||||
|
partnershipId: string, |
||||
|
params?: { startDate?: string; endDate?: string } |
||||
|
): Observable<IPartnershipValuation[]> { |
||||
|
let httpParams = new HttpParams(); |
||||
|
if (params?.startDate) { |
||||
|
httpParams = httpParams.set('startDate', params.startDate); |
||||
|
} |
||||
|
if (params?.endDate) { |
||||
|
httpParams = httpParams.set('endDate', params.endDate); |
||||
|
} |
||||
|
return this.http.get<IPartnershipValuation[]>( |
||||
|
`/api/v1/partnership/${partnershipId}/valuations`, |
||||
|
{ params: httpParams } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public addPartnershipAsset( |
||||
|
partnershipId: string, |
||||
|
data: { |
||||
|
name: string; |
||||
|
assetType: string; |
||||
|
description?: string; |
||||
|
acquisitionDate?: string; |
||||
|
acquisitionCost?: number; |
||||
|
currentValue?: number; |
||||
|
currency: string; |
||||
|
metadata?: Record<string, unknown>; |
||||
|
} |
||||
|
): Observable<any> { |
||||
|
return this.http.post(`/api/v1/partnership/${partnershipId}/asset`, data); |
||||
|
} |
||||
|
|
||||
|
public addAssetValuation( |
||||
|
partnershipId: string, |
||||
|
assetId: string, |
||||
|
data: { date: string; value: number; source: string; notes?: string } |
||||
|
): Observable<any> { |
||||
|
return this.http.post( |
||||
|
`/api/v1/partnership/${partnershipId}/asset/${assetId}/valuation`, |
||||
|
data |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public fetchPartnershipPerformance( |
||||
|
partnershipId: string, |
||||
|
params?: { |
||||
|
periodicity?: string; |
||||
|
benchmarks?: string; |
||||
|
startDate?: string; |
||||
|
endDate?: string; |
||||
|
} |
||||
|
): Observable<IPartnershipPerformance> { |
||||
|
let httpParams = new HttpParams(); |
||||
|
if (params?.periodicity) { |
||||
|
httpParams = httpParams.set('periodicity', params.periodicity); |
||||
|
} |
||||
|
if (params?.benchmarks) { |
||||
|
httpParams = httpParams.set('benchmarks', params.benchmarks); |
||||
|
} |
||||
|
if (params?.startDate) { |
||||
|
httpParams = httpParams.set('startDate', params.startDate); |
||||
|
} |
||||
|
if (params?.endDate) { |
||||
|
httpParams = httpParams.set('endDate', params.endDate); |
||||
|
} |
||||
|
return this.http.get<IPartnershipPerformance>( |
||||
|
`/api/v1/partnership/${partnershipId}/performance`, |
||||
|
{ params: httpParams } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Distribution endpoints
|
||||
|
public createDistribution(data: { |
||||
|
partnershipId?: string; |
||||
|
entityId: string; |
||||
|
type: string; |
||||
|
amount: number; |
||||
|
date: string; |
||||
|
currency: string; |
||||
|
taxWithheld?: number; |
||||
|
notes?: string; |
||||
|
}): Observable<any> { |
||||
|
return this.http.post('/api/v1/distribution', data); |
||||
|
} |
||||
|
|
||||
|
public fetchDistributions(params?: { |
||||
|
entityId?: string; |
||||
|
partnershipId?: string; |
||||
|
type?: string; |
||||
|
startDate?: string; |
||||
|
endDate?: string; |
||||
|
groupBy?: string; |
||||
|
}): Observable<IDistributionListResponse> { |
||||
|
let httpParams = new HttpParams(); |
||||
|
if (params) { |
||||
|
Object.entries(params).forEach(([key, value]) => { |
||||
|
if (value) { |
||||
|
httpParams = httpParams.set(key, value); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
return this.http.get<IDistributionListResponse>('/api/v1/distribution', { |
||||
|
params: httpParams |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public deleteDistribution(distributionId: string): Observable<void> { |
||||
|
return this.http.delete<void>(`/api/v1/distribution/${distributionId}`); |
||||
|
} |
||||
|
|
||||
|
// K-Document endpoints
|
||||
|
public createKDocument(data: { |
||||
|
partnershipId: string; |
||||
|
type: string; |
||||
|
taxYear: number; |
||||
|
filingStatus?: string; |
||||
|
data: Record<string, number>; |
||||
|
}): Observable<IKDocument> { |
||||
|
return this.http.post<IKDocument>('/api/v1/k-document', data); |
||||
|
} |
||||
|
|
||||
|
public fetchKDocuments(params?: { |
||||
|
partnershipId?: string; |
||||
|
taxYear?: number; |
||||
|
type?: string; |
||||
|
filingStatus?: string; |
||||
|
}): Observable<IKDocument[]> { |
||||
|
let httpParams = new HttpParams(); |
||||
|
if (params) { |
||||
|
Object.entries(params).forEach(([key, value]) => { |
||||
|
if (value !== undefined && value !== null) { |
||||
|
httpParams = httpParams.set(key, value.toString()); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
return this.http.get<IKDocument[]>('/api/v1/k-document', { |
||||
|
params: httpParams |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public updateKDocument( |
||||
|
kDocumentId: string, |
||||
|
data: { filingStatus?: string; data?: Record<string, number> } |
||||
|
): Observable<IKDocument> { |
||||
|
return this.http.put<IKDocument>(`/api/v1/k-document/${kDocumentId}`, data); |
||||
|
} |
||||
|
|
||||
|
public linkDocumentToKDocument( |
||||
|
kDocumentId: string, |
||||
|
documentId: string |
||||
|
): Observable<IKDocument> { |
||||
|
return this.http.post<IKDocument>( |
||||
|
`/api/v1/k-document/${kDocumentId}/link-document`, |
||||
|
{ documentId } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Upload endpoints
|
||||
|
public uploadDocument(formData: FormData): Observable<any> { |
||||
|
return this.http.post('/api/v1/upload', formData); |
||||
|
} |
||||
|
|
||||
|
public downloadDocument(documentId: string): Observable<Blob> { |
||||
|
return this.http.get(`/api/v1/upload/${documentId}/download`, { |
||||
|
responseType: 'blob' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Family Office endpoints
|
||||
|
public fetchDashboard(): Observable<IFamilyOfficeDashboard> { |
||||
|
return this.http.get<IFamilyOfficeDashboard>( |
||||
|
'/api/v1/family-office/dashboard' |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public fetchReport(params: { |
||||
|
period: string; |
||||
|
year: number; |
||||
|
periodNumber?: number; |
||||
|
entityId?: string; |
||||
|
benchmarks?: string; |
||||
|
}): Observable<IFamilyOfficeReport> { |
||||
|
let httpParams = new HttpParams() |
||||
|
.set('period', params.period) |
||||
|
.set('year', params.year.toString()); |
||||
|
|
||||
|
if (params.periodNumber) { |
||||
|
httpParams = httpParams.set( |
||||
|
'periodNumber', |
||||
|
params.periodNumber.toString() |
||||
|
); |
||||
|
} |
||||
|
if (params.entityId) { |
||||
|
httpParams = httpParams.set('entityId', params.entityId); |
||||
|
} |
||||
|
if (params.benchmarks) { |
||||
|
httpParams = httpParams.set('benchmarks', params.benchmarks); |
||||
|
} |
||||
|
return this.http.get<IFamilyOfficeReport>('/api/v1/family-office/report', { |
||||
|
params: httpParams |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue