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", |
|||
"editor.formatOnSave": true |
|||
"chat.promptFilesRecommendations": { |
|||
"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