Add MVP implementations for Atlas AI Wealth and ExecutiveOS AI
Two product MVPs based on iterations of PRD.md / TRD.md:
1. Atlas AI Wealth (initial TRD: portfolio/cashflow/forecasting platform)
- backend/ FastAPI + SQLAlchemy + SQLite: XLSX/CSV parser with
fuzzy header matching, rule-based transaction classifier,
analytics engine (TWR via Modified Dietz, Newton IRR,
moving-avg cost, allocation, cashflow), AI Copilot stub.
- frontend/ Next.js 14 + TS + Tailwind: Dashboard, Portfolio,
Transactions, Cash Flow, Upload screens; SWR data fetching.
- index.html / app.js / data.js / styles.css vanilla zero-build
version of the same MVP using mock data.
2. ExecutiveOS AI (revised TRD: AI Chief of Staff for Lotus Notes)
- executive-os/ vanilla HTML+CSS+JS PWA: Daily Briefing,
Priority Inbox, Approvals, Escalations, AI Search, AI Copilot.
- Mock dataset with 18 emails (P1-P3, 8 categories, AI scoring,
SLA tracking, recommendations).
- Russian UI. Service Worker + manifest for offline install.
Docs:
- README.md run instructions + TRD coverage table for both MVPs
- README.korabayev.md preserved original onboarding doc
This commit is contained in:
parent
1d06f2a213
commit
8d92862d8b
465
PRD.md
465
PRD.md
@ -0,0 +1,465 @@
|
|||||||
|
# PRD — ExecutiveOS AI
|
||||||
|
# AI Executive Mail & Workflow Copilot
|
||||||
|
|
||||||
|
Version: 1.0
|
||||||
|
Date: 2026-05-22
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Product Overview
|
||||||
|
|
||||||
|
ExecutiveOS AI is an AI-native executive assistant platform designed for:
|
||||||
|
- executives;
|
||||||
|
- board members;
|
||||||
|
- senior management;
|
||||||
|
- strategy teams;
|
||||||
|
- finance leaders.
|
||||||
|
|
||||||
|
The system integrates with:
|
||||||
|
- Lotus Notes / HCL Notes;
|
||||||
|
- corporate email;
|
||||||
|
- calendars;
|
||||||
|
- approval workflows;
|
||||||
|
- board processes.
|
||||||
|
|
||||||
|
The platform automatically:
|
||||||
|
- reads incoming emails;
|
||||||
|
- prioritizes communications;
|
||||||
|
- summarizes threads;
|
||||||
|
- identifies required actions;
|
||||||
|
- recommends decisions;
|
||||||
|
- tracks pending approvals;
|
||||||
|
- reminds users about critical items.
|
||||||
|
|
||||||
|
The product acts as:
|
||||||
|
> “AI Chief of Staff for executives.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Problem Statement
|
||||||
|
|
||||||
|
Senior executives face:
|
||||||
|
- excessive email volume;
|
||||||
|
- overloaded workflows;
|
||||||
|
- delayed approvals;
|
||||||
|
- missed critical items;
|
||||||
|
- unread board materials;
|
||||||
|
- fragmented communication.
|
||||||
|
|
||||||
|
Current workflow is:
|
||||||
|
- manual;
|
||||||
|
- inefficient;
|
||||||
|
- reactive;
|
||||||
|
- difficult to prioritize.
|
||||||
|
|
||||||
|
Executives spend significant time:
|
||||||
|
- reading emails;
|
||||||
|
- searching for context;
|
||||||
|
- following up;
|
||||||
|
- remembering pending approvals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Product Vision
|
||||||
|
|
||||||
|
Create:
|
||||||
|
> “An AI operating system for executive decision-making and workflow management.”
|
||||||
|
|
||||||
|
The platform should become:
|
||||||
|
- the main executive dashboard;
|
||||||
|
- approval control center;
|
||||||
|
- AI productivity layer;
|
||||||
|
- intelligent workflow assistant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Product Goals
|
||||||
|
|
||||||
|
## G1 — Inbox Intelligence
|
||||||
|
AI automatically:
|
||||||
|
- reads emails;
|
||||||
|
- detects urgency;
|
||||||
|
- identifies actions;
|
||||||
|
- prioritizes messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G2 — Decision Assistance
|
||||||
|
AI recommends:
|
||||||
|
- approve;
|
||||||
|
- reject;
|
||||||
|
- sign;
|
||||||
|
- vote;
|
||||||
|
- review;
|
||||||
|
- delegate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G3 — Executive Productivity
|
||||||
|
Reduce:
|
||||||
|
- email reading time;
|
||||||
|
- missed approvals;
|
||||||
|
- delayed responses;
|
||||||
|
- operational overload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G4 — Action Monitoring
|
||||||
|
Track:
|
||||||
|
- pending approvals;
|
||||||
|
- unresolved tasks;
|
||||||
|
- unread critical emails;
|
||||||
|
- SLA breaches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Core Features
|
||||||
|
|
||||||
|
# 5.1 AI Smart Inbox
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- automatic prioritization;
|
||||||
|
- executive summaries;
|
||||||
|
- urgent item detection;
|
||||||
|
- duplicate filtering;
|
||||||
|
- AI importance scoring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
AI highlights:
|
||||||
|
> “Chairman approval request pending for 2 days.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5.2 AI Recommendations
|
||||||
|
|
||||||
|
For each item AI suggests:
|
||||||
|
- approve;
|
||||||
|
- reject;
|
||||||
|
- review;
|
||||||
|
- sign;
|
||||||
|
- escalate;
|
||||||
|
- delegate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
> “Low operational risk. Recommended action: Approve.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5.3 Executive Daily Briefing
|
||||||
|
|
||||||
|
Every morning AI generates:
|
||||||
|
- top priorities;
|
||||||
|
- urgent issues;
|
||||||
|
- pending approvals;
|
||||||
|
- board matters;
|
||||||
|
- key deadlines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5.4 Reminder Engine
|
||||||
|
|
||||||
|
AI reminds:
|
||||||
|
- unread important emails;
|
||||||
|
- delayed approvals;
|
||||||
|
- overdue responses;
|
||||||
|
- unresolved escalations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5.5 AI Summarization
|
||||||
|
|
||||||
|
AI summarizes:
|
||||||
|
- long email chains;
|
||||||
|
- attachments;
|
||||||
|
- board materials;
|
||||||
|
- presentations;
|
||||||
|
- PDFs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5.6 Workflow Tracking
|
||||||
|
|
||||||
|
Tracks:
|
||||||
|
- approval chains;
|
||||||
|
- document status;
|
||||||
|
- response timelines;
|
||||||
|
- delegated tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5.7 AI Search
|
||||||
|
|
||||||
|
User can ask:
|
||||||
|
- “What approvals are pending?”
|
||||||
|
- “Find all emails about budget revision.”
|
||||||
|
- “What requires signature today?”
|
||||||
|
- “Summarize unread board materials.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. User Personas
|
||||||
|
|
||||||
|
## Executive
|
||||||
|
Needs:
|
||||||
|
- prioritization;
|
||||||
|
- decision support;
|
||||||
|
- reminders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Board Member
|
||||||
|
Needs:
|
||||||
|
- concise summaries;
|
||||||
|
- voting recommendations;
|
||||||
|
- board tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chief of Staff
|
||||||
|
Needs:
|
||||||
|
- workflow visibility;
|
||||||
|
- escalation monitoring;
|
||||||
|
- coordination tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Main User Flows
|
||||||
|
|
||||||
|
# UF1 — Email Processing
|
||||||
|
|
||||||
|
1. Email arrives
|
||||||
|
2. AI analyzes content
|
||||||
|
3. AI assigns priority
|
||||||
|
4. AI detects action required
|
||||||
|
5. AI generates recommendation
|
||||||
|
6. Dashboard updates
|
||||||
|
7. Reminder logic activated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UF2 — Approval Workflow
|
||||||
|
|
||||||
|
1. Approval request received
|
||||||
|
2. AI analyzes:
|
||||||
|
- sender;
|
||||||
|
- content;
|
||||||
|
- risk;
|
||||||
|
- deadline.
|
||||||
|
3. AI suggests:
|
||||||
|
- approve;
|
||||||
|
- review;
|
||||||
|
- escalate.
|
||||||
|
4. User acts
|
||||||
|
5. Workflow updated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UF3 — Executive Briefing
|
||||||
|
|
||||||
|
1. AI collects:
|
||||||
|
- unread emails;
|
||||||
|
- escalations;
|
||||||
|
- approvals;
|
||||||
|
- meetings.
|
||||||
|
2. AI generates summary
|
||||||
|
3. Briefing delivered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Functional Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| FR-1 | Lotus integration | High |
|
||||||
|
| FR-2 | Email ingestion | High |
|
||||||
|
| FR-3 | AI summarization | High |
|
||||||
|
| FR-4 | AI recommendations | High |
|
||||||
|
| FR-5 | Smart reminders | High |
|
||||||
|
| FR-6 | Workflow tracking | High |
|
||||||
|
| FR-7 | Semantic search | Medium |
|
||||||
|
| FR-8 | Mobile notifications | Medium |
|
||||||
|
| FR-9 | Telegram integration | Medium |
|
||||||
|
| FR-10 | AI daily briefings | High |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. AI Features
|
||||||
|
|
||||||
|
# 9.1 AI Priority Scoring
|
||||||
|
|
||||||
|
AI determines:
|
||||||
|
- urgency;
|
||||||
|
- strategic importance;
|
||||||
|
- sender hierarchy;
|
||||||
|
- deadline sensitivity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9.2 AI Recommendation Engine
|
||||||
|
|
||||||
|
AI evaluates:
|
||||||
|
- operational risk;
|
||||||
|
- historical patterns;
|
||||||
|
- workflow context;
|
||||||
|
- required approvals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9.3 AI Summaries
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- short summary;
|
||||||
|
- executive summary;
|
||||||
|
- action summary;
|
||||||
|
- risk summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9.4 AI Risk Detection
|
||||||
|
|
||||||
|
AI flags:
|
||||||
|
- delayed approvals;
|
||||||
|
- unanswered critical emails;
|
||||||
|
- escalation risks;
|
||||||
|
- operational bottlenecks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Integrations
|
||||||
|
|
||||||
|
## Email Systems
|
||||||
|
- HCL Notes / Lotus Notes
|
||||||
|
- Outlook
|
||||||
|
- Exchange
|
||||||
|
- Gmail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collaboration
|
||||||
|
- Teams
|
||||||
|
- Slack
|
||||||
|
- Telegram
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
- LDAP
|
||||||
|
- Active Directory
|
||||||
|
- SSO
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Dashboard Requirements
|
||||||
|
|
||||||
|
## Main Widgets
|
||||||
|
|
||||||
|
### Priority Inbox
|
||||||
|
Critical emails first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI Recommendations
|
||||||
|
Approve / Review / Sign.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pending Approvals
|
||||||
|
Shows overdue items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Executive Briefing
|
||||||
|
Morning summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Escalation Monitor
|
||||||
|
Tracks unresolved issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Security Requirements
|
||||||
|
|
||||||
|
## Security Standards
|
||||||
|
- AES-256 encryption;
|
||||||
|
- TLS 1.3;
|
||||||
|
- MFA;
|
||||||
|
- audit logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Access Controls
|
||||||
|
- role-based access;
|
||||||
|
- least privilege model;
|
||||||
|
- approval tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Performance Requirements
|
||||||
|
|
||||||
|
| Metric | Target |
|
||||||
|
|---|---|
|
||||||
|
| Email classification | <5 sec |
|
||||||
|
| AI recommendation | <10 sec |
|
||||||
|
| Dashboard load | <2 sec |
|
||||||
|
| Search response | <2 sec |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Deployment
|
||||||
|
|
||||||
|
## Options
|
||||||
|
- on-premise;
|
||||||
|
- hybrid cloud;
|
||||||
|
- private cloud.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. MVP Scope
|
||||||
|
|
||||||
|
## MVP Includes
|
||||||
|
- Lotus integration;
|
||||||
|
- email ingestion;
|
||||||
|
- AI summaries;
|
||||||
|
- AI recommendations;
|
||||||
|
- reminders;
|
||||||
|
- dashboard;
|
||||||
|
- Telegram alerts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Success Metrics
|
||||||
|
|
||||||
|
| KPI | Target |
|
||||||
|
|---|---|
|
||||||
|
| Email processing reduction | >60% |
|
||||||
|
| Reminder effectiveness | >80% |
|
||||||
|
| AI recommendation acceptance | >70% |
|
||||||
|
| Missed approvals reduction | >50% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. Future Roadmap
|
||||||
|
|
||||||
|
## Phase 2
|
||||||
|
- AI meeting assistant;
|
||||||
|
- voice summaries;
|
||||||
|
- auto-replies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3
|
||||||
|
- autonomous workflow handling;
|
||||||
|
- AI board secretary;
|
||||||
|
- AI negotiation assistant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4
|
||||||
|
- enterprise executive operating system;
|
||||||
|
- AI chief of staff;
|
||||||
|
- organization-wide intelligence graph.
|
||||||
26
README.korabayev.md
Normal file
26
README.korabayev.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# korabayev
|
||||||
|
|
||||||
|
Привет, korabayev!
|
||||||
|
|
||||||
|
Это твоя личная папка в среде telecom.quest.
|
||||||
|
- VS Code в браузере: https://code.telecom.quest/?folder=/Users/telecom/dev/_hack22may/korabayev/
|
||||||
|
- Бэкап на Gitea: https://git.telecom.quest/kt-innovators/korabayev
|
||||||
|
|
||||||
|
## Action Plan (как пользоваться)
|
||||||
|
|
||||||
|
В этой папке уже лежат три стартовых файла:
|
||||||
|
- `PRD.md` — здесь ты простыми словами описываешь, что хочешь автоматизировать
|
||||||
|
- `GITEA.md` — справочник по системе Gitea (для Claude, не для тебя)
|
||||||
|
- `TRD.md` — пустой; Claude заполнит его после того, как ты напишешь PRD
|
||||||
|
|
||||||
|
**Шаг 1.** Открой `PRD.md` и напиши свою идею.
|
||||||
|
**Шаг 2.** Открой Claude (Desktop или web). Дай ему `PRD.md` + `GITEA.md` и попроси: «Сделай TRD.md на основе PRD и GITEA».
|
||||||
|
**Шаг 3.** Сохрани результат в `TRD.md`.
|
||||||
|
**Шаг 4.** Запусти Claude Code в этой папке. Скажи: «Реализуй TRD.md». Получишь рабочий MVP.
|
||||||
|
|
||||||
|
## Как сохранять работу
|
||||||
|
|
||||||
|
В VS Code открой панель "Source Control" (слева), напиши краткое описание, нажми галочку → "Commit and Push".
|
||||||
|
Изменения сохранятся в Gitea и будут доступны при следующем входе.
|
||||||
|
|
||||||
|
В этой папке уже инициализирован git и подключен remote `origin` → Gitea.
|
||||||
168
README.md
168
README.md
@ -1,26 +1,158 @@
|
|||||||
# korabayev
|
# Atlas AI Wealth — MVP
|
||||||
|
|
||||||
Привет, korabayev!
|
AI-native wealth intelligence platform implementing [TRD.md](TRD.md) vertical slice:
|
||||||
|
**Excel upload → parse → classify → portfolio analytics → dashboard**.
|
||||||
|
|
||||||
Это твоя личная папка в среде telecom.quest.
|
> Original onboarding doc: [README.korabayev.md](README.korabayev.md)
|
||||||
- VS Code в браузере: https://code.telecom.quest/?folder=/Users/telecom/dev/_hack22may/korabayev/
|
|
||||||
- Бэкап на Gitea: https://git.telecom.quest/kt-innovators/korabayev
|
|
||||||
|
|
||||||
## Action Plan (как пользоваться)
|
---
|
||||||
|
|
||||||
В этой папке уже лежат три стартовых файла:
|
## Stack
|
||||||
- `PRD.md` — здесь ты простыми словами описываешь, что хочешь автоматизировать
|
|
||||||
- `GITEA.md` — справочник по системе Gitea (для Claude, не для тебя)
|
|
||||||
- `TRD.md` — пустой; Claude заполнит его после того, как ты напишешь PRD
|
|
||||||
|
|
||||||
**Шаг 1.** Открой `PRD.md` и напиши свою идею.
|
| Layer | Tech |
|
||||||
**Шаг 2.** Открой Claude (Desktop или web). Дай ему `PRD.md` + `GITEA.md` и попроси: «Сделай TRD.md на основе PRD и GITEA».
|
| ----------- | ------------------------------------------------------- |
|
||||||
**Шаг 3.** Сохрани результат в `TRD.md`.
|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2, SQLite (dev) |
|
||||||
**Шаг 4.** Запусти Claude Code в этой папке. Скажи: «Реализуй TRD.md». Получишь рабочий MVP.
|
| Parser | pandas, openpyxl — multi-sheet XLSX/CSV, fuzzy headers |
|
||||||
|
| Analytics | Modified Dietz TWR, Newton IRR, moving-avg cost, allocation |
|
||||||
|
| AI Copilot | Rule-based templated insights (LLM plug-in stub) |
|
||||||
|
| Frontend | Next.js 14, React 18, TypeScript, Tailwind, SWR |
|
||||||
|
|
||||||
## Как сохранять работу
|
---
|
||||||
|
|
||||||
В VS Code открой панель "Source Control" (слева), напиши краткое описание, нажми галочку → "Commit and Push".
|
## TRD Coverage
|
||||||
Изменения сохранятся в Gitea и будут доступны при следующем входе.
|
|
||||||
|
|
||||||
В этой папке уже инициализирован git и подключен remote `origin` → Gitea.
|
| TRD § | Component | Status |
|
||||||
|
| ------ | -------------------------------------- | ----------------------------------- |
|
||||||
|
| §3 | Frontend (Next/TS/Tailwind) | Dashboard, Portfolio, Tx, CashFlow, Upload |
|
||||||
|
| §4 | FastAPI REST + OpenAPI + JWT auth | REST + OpenAPI ✓, auth = stub user |
|
||||||
|
| §5 | PostgreSQL / Redis | SQLite (swap via `DATABASE_URL`) |
|
||||||
|
| §6 | Core data models | User, Account, Transaction, Position ✓ |
|
||||||
|
| §7 | File processing (XLSX/CSV/PDF) | XLSX + CSV ✓, PDF deferred |
|
||||||
|
| §8 | Classification (rule + AI) | Rule layer ✓, AI layer = stub |
|
||||||
|
| §9 | Portfolio analytics | TWR, IRR, PnL, allocation ✓ |
|
||||||
|
| §10 | Market data (Yahoo/Polygon/…) | Stub prices (wire `yfinance` later) |
|
||||||
|
| §11 | Forecasting (ARIMA/XGB/LSTM) | Deferred |
|
||||||
|
| §12 | AI Copilot (OpenAI/Anthropic + RAG) | Rule-based stub answers ✓ |
|
||||||
|
| §13–18 | Security/Infra/DevOps/Tests | Phase 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run It
|
||||||
|
|
||||||
|
### 1. Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
/opt/homebrew/bin/python3.12 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Generate a sample broker statement (optional)
|
||||||
|
.venv/bin/python -m scripts.generate_sample
|
||||||
|
|
||||||
|
# Start API
|
||||||
|
.venv/bin/uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
API: `http://localhost:8000` · OpenAPI docs: `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
### 2. Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env.local # adjust NEXT_PUBLIC_API_BASE if needed
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
App: `http://localhost:3000`
|
||||||
|
|
||||||
|
### 3. End-to-End Demo
|
||||||
|
|
||||||
|
1. Open `http://localhost:3000/upload`.
|
||||||
|
2. Upload `backend/sample_broker_statement.xlsx`.
|
||||||
|
3. Visit Dashboard → Portfolio → Cash Flow → Transactions.
|
||||||
|
4. Ask AI Copilot from the Dashboard: *"what is my performance?"*, *"how much did I spend?"*, *"what is my concentration risk?"*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
POST /upload multipart/form-data file=<xlsx|csv>
|
||||||
|
GET /accounts
|
||||||
|
GET /transactions?transaction_type=...&asset=...&q=...
|
||||||
|
GET /portfolio/metrics
|
||||||
|
GET /cashflow/summary?year=2026
|
||||||
|
GET /cashflow/spending?year=2026
|
||||||
|
GET /cashflow/dividends?year=2026
|
||||||
|
GET /dashboard
|
||||||
|
POST /copilot/ask { "question": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
app/
|
||||||
|
main.py FastAPI app + CORS + router registration
|
||||||
|
config.py Pydantic settings (env-driven)
|
||||||
|
db.py SQLAlchemy engine/session
|
||||||
|
models.py User, Account, Transaction, Position
|
||||||
|
schemas.py Pydantic response models
|
||||||
|
parser.py XLSX/CSV multi-sheet broker parser
|
||||||
|
classifier.py Rule-based tx type + spending category
|
||||||
|
analytics.py TWR (Mod. Dietz), IRR, PnL, allocation, cashflow
|
||||||
|
market_data.py Price stub (fallback dict)
|
||||||
|
services.py Parser+classifier+persistence orchestration
|
||||||
|
deps.py FastAPI deps (current_user stub)
|
||||||
|
routes/
|
||||||
|
upload.py
|
||||||
|
accounts.py
|
||||||
|
transactions.py
|
||||||
|
portfolio.py
|
||||||
|
cashflow.py
|
||||||
|
dashboard.py
|
||||||
|
copilot.py
|
||||||
|
scripts/
|
||||||
|
generate_sample.py Produces sample_broker_statement.xlsx
|
||||||
|
requirements.txt
|
||||||
|
.env.example
|
||||||
|
frontend/
|
||||||
|
app/ Next.js App Router pages
|
||||||
|
page.tsx Dashboard
|
||||||
|
portfolio/page.tsx
|
||||||
|
transactions/page.tsx
|
||||||
|
cashflow/page.tsx
|
||||||
|
upload/page.tsx
|
||||||
|
layout.tsx
|
||||||
|
globals.css
|
||||||
|
components/
|
||||||
|
Nav.tsx
|
||||||
|
Copilot.tsx
|
||||||
|
AllocationBar.tsx
|
||||||
|
lib/
|
||||||
|
api.ts fetcher + formatters
|
||||||
|
types.ts API response types
|
||||||
|
package.json
|
||||||
|
tailwind.config.js
|
||||||
|
tsconfig.json
|
||||||
|
next.config.js
|
||||||
|
PRD.md
|
||||||
|
TRD.md
|
||||||
|
README.md (this file)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Stubbed (Phase 2+)
|
||||||
|
|
||||||
|
- **Auth**: single demo user. JWT/OAuth2/MFA → TRD §13.
|
||||||
|
- **Market data**: in-memory fallback prices. Wire `yfinance` or Polygon in `market_data.py`.
|
||||||
|
- **AI Copilot LLM**: templated insights. Replace `routes/copilot.py:ask` with OpenAI/Anthropic + RAG (TRD §12).
|
||||||
|
- **PDF parsing**: only XLSX/CSV today.
|
||||||
|
- **Forecasting engine**: TRD §11 not implemented.
|
||||||
|
- **Postgres**: SQLite in dev. Set `DATABASE_URL=postgresql://...` to switch.
|
||||||
|
- **Redis/Celery/S3/CloudFront**: TRD §14 infra deferred.
|
||||||
|
|||||||
351
TRD.md
351
TRD.md
@ -0,0 +1,351 @@
|
|||||||
|
# TRD — AI Executive Mail & Workflow Copilot
|
||||||
|
# Codename: ExecutiveOS AI
|
||||||
|
|
||||||
|
Version: 1.0
|
||||||
|
Date: 2026-05-22
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Technical Overview
|
||||||
|
|
||||||
|
ExecutiveOS AI is an AI-native executive workflow assistant designed for senior management and board-level users.
|
||||||
|
|
||||||
|
The platform integrates with:
|
||||||
|
- Lotus Notes / HCL Notes;
|
||||||
|
- corporate email;
|
||||||
|
- calendar systems;
|
||||||
|
- board workflows;
|
||||||
|
- approval processes.
|
||||||
|
|
||||||
|
The system automatically:
|
||||||
|
- reads incoming emails;
|
||||||
|
- prioritizes important items;
|
||||||
|
- summarizes long threads;
|
||||||
|
- classifies requests;
|
||||||
|
- recommends actions;
|
||||||
|
- reminds the user about pending approvals;
|
||||||
|
- monitors deadlines;
|
||||||
|
- generates executive summaries.
|
||||||
|
|
||||||
|
The product acts as:
|
||||||
|
“AI Chief of Staff for executives.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Business Problem
|
||||||
|
|
||||||
|
Executives receive:
|
||||||
|
- hundreds of emails;
|
||||||
|
- approval requests;
|
||||||
|
- meeting invitations;
|
||||||
|
- board materials;
|
||||||
|
- operational escalations.
|
||||||
|
|
||||||
|
Critical problems:
|
||||||
|
- overload;
|
||||||
|
- missed approvals;
|
||||||
|
- unread emails;
|
||||||
|
- delayed responses;
|
||||||
|
- lack of prioritization;
|
||||||
|
- excessive manual review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Product Goals
|
||||||
|
|
||||||
|
## G1 — Inbox Intelligence
|
||||||
|
AI must automatically:
|
||||||
|
- read emails;
|
||||||
|
- classify importance;
|
||||||
|
- summarize content;
|
||||||
|
- detect urgency;
|
||||||
|
- identify required action.
|
||||||
|
|
||||||
|
## G2 — Executive Decision Support
|
||||||
|
AI recommends:
|
||||||
|
- approve;
|
||||||
|
- reject;
|
||||||
|
- review;
|
||||||
|
- sign;
|
||||||
|
- escalate;
|
||||||
|
- delegate;
|
||||||
|
- vote.
|
||||||
|
|
||||||
|
## G3 — Action Tracking
|
||||||
|
The system tracks:
|
||||||
|
- pending approvals;
|
||||||
|
- unanswered emails;
|
||||||
|
- unread critical messages;
|
||||||
|
- deadlines;
|
||||||
|
- overdue actions.
|
||||||
|
|
||||||
|
## G4 — Executive Productivity
|
||||||
|
Reduce:
|
||||||
|
- email processing time;
|
||||||
|
- approval delays;
|
||||||
|
- missed tasks;
|
||||||
|
- cognitive overload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Core Use Cases
|
||||||
|
|
||||||
|
## UC1 — AI Inbox Processing
|
||||||
|
AI automatically shows:
|
||||||
|
- top priorities;
|
||||||
|
- urgent emails;
|
||||||
|
- pending approvals;
|
||||||
|
- required actions.
|
||||||
|
|
||||||
|
## UC2 — AI Recommendation Engine
|
||||||
|
For each email AI suggests:
|
||||||
|
- approve;
|
||||||
|
- sign;
|
||||||
|
- vote;
|
||||||
|
- review;
|
||||||
|
- delegate;
|
||||||
|
- ignore.
|
||||||
|
|
||||||
|
## UC3 — Executive Summary
|
||||||
|
AI generates:
|
||||||
|
- daily digest;
|
||||||
|
- morning briefing;
|
||||||
|
- operational summary.
|
||||||
|
|
||||||
|
## UC4 — Smart Reminders
|
||||||
|
AI reminds user:
|
||||||
|
- unread critical emails;
|
||||||
|
- pending approvals;
|
||||||
|
- unresolved escalations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. System Architecture
|
||||||
|
|
||||||
|
## Main Components
|
||||||
|
|
||||||
|
1. Lotus Integration Service
|
||||||
|
2. Email Ingestion Engine
|
||||||
|
3. AI Classification Engine
|
||||||
|
4. AI Recommendation Engine
|
||||||
|
5. Workflow Tracking Engine
|
||||||
|
6. Reminder Service
|
||||||
|
7. Notification Engine
|
||||||
|
8. Executive Dashboard
|
||||||
|
9. Search & Knowledge Engine
|
||||||
|
10. Security Layer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Lotus / Email Integration
|
||||||
|
|
||||||
|
## Supported Systems
|
||||||
|
- HCL Notes / Lotus Notes
|
||||||
|
- Microsoft Exchange
|
||||||
|
- Outlook
|
||||||
|
- Gmail
|
||||||
|
- IMAP/SMTP
|
||||||
|
|
||||||
|
## Integration Methods
|
||||||
|
- IMAP
|
||||||
|
- Exchange APIs
|
||||||
|
- Microsoft Graph
|
||||||
|
- Domino APIs
|
||||||
|
- NSF parsing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Email Ingestion Pipeline
|
||||||
|
|
||||||
|
1. Fetch emails
|
||||||
|
2. Parse metadata
|
||||||
|
3. Extract attachments
|
||||||
|
4. Detect language
|
||||||
|
5. AI classification
|
||||||
|
6. Priority scoring
|
||||||
|
7. Action recommendation
|
||||||
|
8. Store indexed data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. AI Classification Engine
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
| Category | Examples |
|
||||||
|
|---|---|
|
||||||
|
| Approval | Sign/approve requests |
|
||||||
|
| Board | Board materials |
|
||||||
|
| Escalation | Critical incidents |
|
||||||
|
| Financial | Budget/finance |
|
||||||
|
| Legal | Contracts |
|
||||||
|
| HR | Staffing |
|
||||||
|
| Informational | FYI |
|
||||||
|
| Urgent | Immediate action |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. AI Recommendation Engine
|
||||||
|
|
||||||
|
| Recommendation | Description |
|
||||||
|
|---|---|
|
||||||
|
| Approve | Safe to approve |
|
||||||
|
| Review | Needs review |
|
||||||
|
| Reject | Potential issue |
|
||||||
|
| Sign | Requires signature |
|
||||||
|
| Vote | Requires board vote |
|
||||||
|
| Delegate | Forward to subordinate |
|
||||||
|
| Ignore | Low relevance |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Executive Dashboard
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
- Priority Inbox
|
||||||
|
- AI Recommendations
|
||||||
|
- Pending Tasks
|
||||||
|
- Daily Briefing
|
||||||
|
- Escalation Monitor
|
||||||
|
- Approval Tracker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Reminder Engine
|
||||||
|
|
||||||
|
## Reminder Examples
|
||||||
|
- “Board approval pending for 3 days.”
|
||||||
|
- “Unread email from Chairman.”
|
||||||
|
- “Contract awaiting signature.”
|
||||||
|
|
||||||
|
## Reminder Channels
|
||||||
|
- desktop notifications;
|
||||||
|
- Telegram;
|
||||||
|
- email;
|
||||||
|
- Teams/Slack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. AI Summarization Engine
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
- email summary;
|
||||||
|
- thread summary;
|
||||||
|
- attachment summary.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
“Finance department requests approval for revised CAPEX allocation.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Knowledge Search Engine
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- semantic email search;
|
||||||
|
- attachment search;
|
||||||
|
- board decision retrieval;
|
||||||
|
- historical approvals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. AI Copilot
|
||||||
|
|
||||||
|
## Example Questions
|
||||||
|
- “What requires my approval today?”
|
||||||
|
- “Summarize unread emails.”
|
||||||
|
- “Which items are overdue?”
|
||||||
|
- “What should I prioritize?”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Security Requirements
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
- SSO
|
||||||
|
- LDAP
|
||||||
|
- Active Directory
|
||||||
|
- MFA
|
||||||
|
|
||||||
|
## Encryption
|
||||||
|
- AES-256
|
||||||
|
- TLS 1.3
|
||||||
|
|
||||||
|
## Compliance
|
||||||
|
- audit logs;
|
||||||
|
- role-based access;
|
||||||
|
- zero-trust principles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Infrastructure
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
- On-premise
|
||||||
|
- Hybrid cloud
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Kubernetes
|
||||||
|
- Docker
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis
|
||||||
|
- Elasticsearch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. AI Architecture
|
||||||
|
|
||||||
|
## LLM Providers
|
||||||
|
- OpenAI
|
||||||
|
- Azure OpenAI
|
||||||
|
- Anthropic
|
||||||
|
- local LLMs
|
||||||
|
|
||||||
|
## RAG Pipeline
|
||||||
|
1. Retrieve email context
|
||||||
|
2. Retrieve workflow context
|
||||||
|
3. Build prompt
|
||||||
|
4. Generate recommendation
|
||||||
|
5. Return explanation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 18. Notification Engine
|
||||||
|
|
||||||
|
## Channels
|
||||||
|
- Telegram bot;
|
||||||
|
- Microsoft Teams;
|
||||||
|
- Slack;
|
||||||
|
- mobile app;
|
||||||
|
- desktop alerts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 19. Monitoring
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Prometheus
|
||||||
|
- Grafana
|
||||||
|
- Sentry
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
- unread emails;
|
||||||
|
- SLA violations;
|
||||||
|
- AI accuracy;
|
||||||
|
- reminder efficiency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 20. Future Roadmap
|
||||||
|
|
||||||
|
## Phase 2
|
||||||
|
- voice assistant;
|
||||||
|
- AI meeting summaries;
|
||||||
|
- auto-replies.
|
||||||
|
|
||||||
|
## Phase 3
|
||||||
|
- autonomous workflow handling;
|
||||||
|
- AI board secretary.
|
||||||
|
|
||||||
|
## Phase 4
|
||||||
|
- enterprise executive operating system;
|
||||||
|
- AI chief of staff.
|
||||||
505
app.js
Normal file
505
app.js
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
// Atlas AI Wealth — vanilla JS SPA
|
||||||
|
// No build, no deps. Loads window.MOCK_DATA from data.js.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ---------- State ----------
|
||||||
|
const STORAGE_KEY = "atlas_loaded";
|
||||||
|
let DATA = null;
|
||||||
|
let currentRoute = "dashboard";
|
||||||
|
|
||||||
|
function isLoaded() {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) === "1";
|
||||||
|
}
|
||||||
|
function loadData() {
|
||||||
|
DATA = window.MOCK_DATA;
|
||||||
|
localStorage.setItem(STORAGE_KEY, "1");
|
||||||
|
}
|
||||||
|
function resetData() {
|
||||||
|
DATA = null;
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoaded()) DATA = window.MOCK_DATA;
|
||||||
|
|
||||||
|
// ---------- Formatters ----------
|
||||||
|
const fmtMoney = (n, ccy = "USD") =>
|
||||||
|
n == null
|
||||||
|
? "—"
|
||||||
|
: new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: ccy,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(n);
|
||||||
|
|
||||||
|
const fmtPct = (n, digits = 2) =>
|
||||||
|
n == null ? "—" : (n * 100).toFixed(digits) + "%";
|
||||||
|
|
||||||
|
const fmtNum = (n, digits = 2) =>
|
||||||
|
n == null
|
||||||
|
? "—"
|
||||||
|
: Number(n).toLocaleString("en-US", { maximumFractionDigits: digits });
|
||||||
|
|
||||||
|
const signClass = (n) =>
|
||||||
|
n == null ? "" : n > 0 ? "accent" : n < 0 ? "danger" : "";
|
||||||
|
|
||||||
|
// ---------- Mount template ----------
|
||||||
|
function mount(routeName) {
|
||||||
|
const tpl = document.getElementById("tpl-" + routeName);
|
||||||
|
const view = document.getElementById("view");
|
||||||
|
view.innerHTML = "";
|
||||||
|
if (!tpl) return;
|
||||||
|
view.appendChild(tpl.content.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Empty state ----------
|
||||||
|
function renderEmpty() {
|
||||||
|
const view = document.getElementById("view");
|
||||||
|
view.innerHTML = `
|
||||||
|
<section>
|
||||||
|
<div class="card stack">
|
||||||
|
<div class="kpi-label">Нет данных</div>
|
||||||
|
<p class="muted">
|
||||||
|
Открой <strong>Upload</strong> и нажми «Load sample data» —
|
||||||
|
появится демо-портфель (Interactive Brokers, 27 транзакций 2024–2026).
|
||||||
|
</p>
|
||||||
|
<div class="row gap">
|
||||||
|
<button class="btn-primary" id="empty-load">Загрузить sample data</button>
|
||||||
|
<button class="btn-ghost" id="empty-upload">Open Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
document.getElementById("empty-load").onclick = () => {
|
||||||
|
loadData();
|
||||||
|
navigate(currentRoute);
|
||||||
|
};
|
||||||
|
document.getElementById("empty-upload").onclick = () => navigate("upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dashboard ----------
|
||||||
|
function renderDashboard() {
|
||||||
|
mount("dashboard");
|
||||||
|
if (!DATA) return renderEmpty();
|
||||||
|
const a = DATA.analytics;
|
||||||
|
|
||||||
|
const txns = DATA.transactions;
|
||||||
|
const year = 2026;
|
||||||
|
const spendYtd = txns.filter(t => t.type === "SPENDING" && t.date.startsWith(String(year)))
|
||||||
|
.reduce((s, t) => s + Math.abs(t.amount), 0);
|
||||||
|
const passiveYtd = txns.filter(t =>
|
||||||
|
(t.type === "DIVIDEND" || t.type === "COUPON") && t.date.startsWith(String(year))
|
||||||
|
).reduce((s, t) => s + Math.abs(t.amount), 0);
|
||||||
|
const spendMtd = txns.filter(t => t.type === "SPENDING" && t.date.startsWith("2026-05"))
|
||||||
|
.reduce((s, t) => s + Math.abs(t.amount), 0);
|
||||||
|
|
||||||
|
setKpi("netWorth", fmtMoney(a.net_worth));
|
||||||
|
setKpi("portfolioValue", fmtMoney(a.total_market_value));
|
||||||
|
setKpi("cash", fmtMoney(a.cash_balance));
|
||||||
|
setKpi("ytd", fmtPct(a.twr_ytd), signClass(a.twr_ytd));
|
||||||
|
setKpi("spendMtd", fmtMoney(spendMtd));
|
||||||
|
setKpi("spendYtd", fmtMoney(spendYtd));
|
||||||
|
setKpi("passive", fmtMoney(passiveYtd));
|
||||||
|
|
||||||
|
const tbody = document.getElementById("top-positions");
|
||||||
|
a.positions.slice(0, 5).forEach((p) => {
|
||||||
|
tbody.insertAdjacentHTML("beforeend", `
|
||||||
|
<tr>
|
||||||
|
<td class="asset">${p.asset}</td>
|
||||||
|
<td class="r">${fmtNum(p.qty)}</td>
|
||||||
|
<td class="r">${fmtMoney(p.avg_cost)}</td>
|
||||||
|
<td class="r">${fmtMoney(p.market_price)}</td>
|
||||||
|
<td class="r">${fmtMoney(p.market_value)}</td>
|
||||||
|
<td class="r ${signClass(p.unrealized_pnl)}">
|
||||||
|
${fmtMoney(p.unrealized_pnl)} (${fmtPct(p.pnl_pct, 1)})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
initCopilot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKpi(key, value, extraClass = "") {
|
||||||
|
const el = document.querySelector(`[data-k="${key}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = value;
|
||||||
|
if (extraClass) el.classList.add(extraClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Copilot ----------
|
||||||
|
const COPILOT_SUGGESTIONS = [
|
||||||
|
"Какая у меня доходность?",
|
||||||
|
"Сколько я потратил за год?",
|
||||||
|
"Какой пассивный доход?",
|
||||||
|
"Где концентрационный риск?"
|
||||||
|
];
|
||||||
|
|
||||||
|
function initCopilot() {
|
||||||
|
const sug = document.getElementById("copilot-suggest");
|
||||||
|
COPILOT_SUGGESTIONS.forEach((q) => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "chip";
|
||||||
|
b.textContent = q;
|
||||||
|
b.onclick = () => askCopilot(q);
|
||||||
|
sug.appendChild(b);
|
||||||
|
});
|
||||||
|
document.getElementById("copilot-ask").onclick = () => {
|
||||||
|
const v = document.getElementById("copilot-input").value.trim();
|
||||||
|
if (v) askCopilot(v);
|
||||||
|
};
|
||||||
|
document.getElementById("copilot-input").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") document.getElementById("copilot-ask").click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function askCopilot(question) {
|
||||||
|
const a = DATA.analytics;
|
||||||
|
const q = question.toLowerCase();
|
||||||
|
const txns = DATA.transactions;
|
||||||
|
const year = 2026;
|
||||||
|
|
||||||
|
const spendYtd = txns.filter(t => t.type === "SPENDING" && t.date.startsWith(String(year)))
|
||||||
|
.reduce((s, t) => s + Math.abs(t.amount), 0);
|
||||||
|
const passiveYtd = txns.filter(t =>
|
||||||
|
(t.type === "DIVIDEND" || t.type === "COUPON") && t.date.startsWith(String(year))
|
||||||
|
).reduce((s, t) => s + Math.abs(t.amount), 0);
|
||||||
|
|
||||||
|
const spendByCat = {};
|
||||||
|
txns.filter(t => t.type === "SPENDING").forEach(t => {
|
||||||
|
const k = t.category || "other";
|
||||||
|
spendByCat[k] = (spendByCat[k] || 0) + Math.abs(t.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalMv = a.total_market_value;
|
||||||
|
const allocations = a.positions.map(p => ({ asset: p.asset, share: p.market_value / totalMv }));
|
||||||
|
const topConc = allocations.sort((x, y) => y.share - x.share)[0];
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
const matchPerf = /доходн|перформ|perform|return|итог/i.test(q);
|
||||||
|
const matchSpend = /потрат|расход|spend|expense|трат/i.test(q);
|
||||||
|
const matchPassive = /пассив|дивиден|купон|passive|divid|coupon/i.test(q);
|
||||||
|
const matchRisk = /риск|концентр|risk|concentr|volat|шарп|drawdown|просад/i.test(q);
|
||||||
|
|
||||||
|
if (matchPerf) {
|
||||||
|
parts.push(
|
||||||
|
`📈 Доходность: TWR YTD ${fmtPct(a.twr_ytd)}, с начала ${fmtPct(a.twr_inception)}, IRR ${fmtPct(a.irr_inception)}. ` +
|
||||||
|
`Нереализованная прибыль ${fmtMoney(a.unrealized_pnl)} (${fmtPct(a.unrealized_pnl_pct)}). ` +
|
||||||
|
`Реализованная прибыль ${fmtMoney(a.realized_pnl)}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (matchSpend) {
|
||||||
|
const top = Object.entries(spendByCat).sort((x, y) => y[1] - x[1])[0];
|
||||||
|
parts.push(
|
||||||
|
`💳 Расходы YTD ${year}: ${fmtMoney(spendYtd)}. ` +
|
||||||
|
`Топ-категория: ${top[0]} (${fmtMoney(top[1])}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (matchPassive) {
|
||||||
|
const coverage = spendYtd > 0 ? passiveYtd / spendYtd : 0;
|
||||||
|
parts.push(
|
||||||
|
`💰 Пассивный доход YTD: ${fmtMoney(passiveYtd)} (дивиденды + купоны). ` +
|
||||||
|
`Покрывает ${(coverage * 100).toFixed(1)}% расходов.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (matchRisk) {
|
||||||
|
parts.push(
|
||||||
|
`⚠️ Риск: топ-позиция ${topConc.asset} = ${(topConc.share * 100).toFixed(1)}% портфеля. ` +
|
||||||
|
`Волатильность ${fmtPct(a.volatility)}, Sharpe ${a.sharpe.toFixed(2)}, max drawdown ${fmtPct(a.max_drawdown)}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push(
|
||||||
|
`🧭 Net worth ${fmtMoney(a.net_worth)}. Портфель ${fmtMoney(a.total_market_value)}, ` +
|
||||||
|
`кэш ${fmtMoney(a.cash_balance)}. TWR YTD ${fmtPct(a.twr_ytd)}.`
|
||||||
|
);
|
||||||
|
parts.push(`Попробуй: «доходность», «расходы», «пассивный доход», «риски».`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = document.getElementById("copilot-answer");
|
||||||
|
out.textContent = parts.join("\n\n");
|
||||||
|
out.classList.add("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Portfolio ----------
|
||||||
|
function renderPortfolio() {
|
||||||
|
mount("portfolio");
|
||||||
|
if (!DATA) return renderEmpty();
|
||||||
|
const a = DATA.analytics;
|
||||||
|
|
||||||
|
setKpi("mv", fmtMoney(a.total_market_value));
|
||||||
|
setKpi("cost", fmtMoney(a.total_cost_basis));
|
||||||
|
setKpi("upnl", `${fmtMoney(a.unrealized_pnl)} (${fmtPct(a.unrealized_pnl_pct)})`, signClass(a.unrealized_pnl));
|
||||||
|
setKpi("rpnl", fmtMoney(a.realized_pnl), signClass(a.realized_pnl));
|
||||||
|
setKpi("twrYtd", fmtPct(a.twr_ytd), signClass(a.twr_ytd));
|
||||||
|
setKpi("twr", fmtPct(a.twr_inception), signClass(a.twr_inception));
|
||||||
|
setKpi("irr", fmtPct(a.irr_inception), signClass(a.irr_inception));
|
||||||
|
setKpi("vol", fmtPct(a.volatility));
|
||||||
|
setKpi("sharpe", a.sharpe.toFixed(2));
|
||||||
|
setKpi("dd", fmtPct(a.max_drawdown), "danger");
|
||||||
|
|
||||||
|
const tbody = document.getElementById("holdings");
|
||||||
|
a.positions.forEach((p) => {
|
||||||
|
const meta = DATA.asset_meta[p.asset] || { sector: "—" };
|
||||||
|
tbody.insertAdjacentHTML("beforeend", `
|
||||||
|
<tr>
|
||||||
|
<td class="asset">${p.asset}</td>
|
||||||
|
<td class="muted small">${meta.sector}</td>
|
||||||
|
<td class="r">${fmtNum(p.qty)}</td>
|
||||||
|
<td class="r">${fmtMoney(p.avg_cost)}</td>
|
||||||
|
<td class="r">${fmtMoney(p.market_price)}</td>
|
||||||
|
<td class="r">${fmtMoney(p.market_value)}</td>
|
||||||
|
<td class="r ${signClass(p.unrealized_pnl)}">
|
||||||
|
${fmtMoney(p.unrealized_pnl)} (${fmtPct(p.pnl_pct, 1)})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAllocation(a.positions, a.total_market_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#5cf2a0", "#7bb7ff", "#ffb454", "#ff5c7a", "#c08bff", "#7ad6ff"];
|
||||||
|
function renderAllocation(positions, total) {
|
||||||
|
const sorted = positions.slice().sort((x, y) => y.market_value - x.market_value);
|
||||||
|
const bar = document.getElementById("alloc-bar");
|
||||||
|
const legend = document.getElementById("alloc-legend");
|
||||||
|
sorted.forEach((p, i) => {
|
||||||
|
const share = p.market_value / total;
|
||||||
|
const c = COLORS[i % COLORS.length];
|
||||||
|
bar.insertAdjacentHTML("beforeend",
|
||||||
|
`<div style="width:${(share * 100).toFixed(2)}%;background:${c}" title="${p.asset} ${(share * 100).toFixed(1)}%"></div>`
|
||||||
|
);
|
||||||
|
legend.insertAdjacentHTML("beforeend", `
|
||||||
|
<div class="item">
|
||||||
|
<span class="dot" style="background:${c}"></span>
|
||||||
|
<span class="mono">${p.asset}</span>
|
||||||
|
<span class="pct">${(share * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Transactions ----------
|
||||||
|
const TX_TYPES = ["BUY","SELL","DIVIDEND","COUPON","DEPOSIT","WITHDRAWAL","SPENDING","TRANSFER","FX","FEE"];
|
||||||
|
const TX_COLOR = {
|
||||||
|
BUY:"warn", SELL:"accent", DIVIDEND:"accent", COUPON:"accent",
|
||||||
|
DEPOSIT:"accent", WITHDRAWAL:"danger", SPENDING:"danger",
|
||||||
|
TRANSFER:"muted", FX:"muted", FEE:"danger"
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderTransactions() {
|
||||||
|
mount("transactions");
|
||||||
|
if (!DATA) return renderEmpty();
|
||||||
|
|
||||||
|
const typeSel = document.getElementById("tx-type");
|
||||||
|
TX_TYPES.forEach(t => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = t; opt.textContent = t;
|
||||||
|
typeSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawRows = () => {
|
||||||
|
const q = document.getElementById("tx-search").value.trim().toLowerCase();
|
||||||
|
const tFilter = typeSel.value;
|
||||||
|
const tbody = document.getElementById("tx-rows");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const rows = DATA.transactions
|
||||||
|
.filter(t => !tFilter || t.type === tFilter)
|
||||||
|
.filter(t => !q || (t.description || "").toLowerCase().includes(q))
|
||||||
|
.slice().reverse();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="7" class="muted r" style="text-align:center;padding:30px">Ничего не найдено.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.forEach(t => {
|
||||||
|
tbody.insertAdjacentHTML("beforeend", `
|
||||||
|
<tr>
|
||||||
|
<td class="mono small">${t.date}</td>
|
||||||
|
<td class="${TX_COLOR[t.type] || ''}">${t.type}</td>
|
||||||
|
<td class="asset">${t.asset || "—"}</td>
|
||||||
|
<td class="r">${t.qty ? fmtNum(t.qty) : "—"}</td>
|
||||||
|
<td class="r ${t.amount >= 0 ? 'accent' : 'danger'}">${fmtMoney(t.amount)}</td>
|
||||||
|
<td class="muted small">${t.category || "—"}</td>
|
||||||
|
<td class="muted small">${t.description || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("tx-search").addEventListener("input", drawRows);
|
||||||
|
typeSel.addEventListener("change", drawRows);
|
||||||
|
drawRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Cash Flow ----------
|
||||||
|
function renderCashflow() {
|
||||||
|
mount("cashflow");
|
||||||
|
if (!DATA) return renderEmpty();
|
||||||
|
|
||||||
|
const txns = DATA.transactions;
|
||||||
|
const inflowTypes = new Set(["DEPOSIT", "DIVIDEND", "COUPON"]);
|
||||||
|
const outflowTypes = new Set(["WITHDRAWAL", "SPENDING", "FEE"]);
|
||||||
|
|
||||||
|
let inflows = 0, outflows = 0;
|
||||||
|
const spendByCat = {};
|
||||||
|
const divByAsset = {};
|
||||||
|
const monthly = {}; // { "2024-01": {in, out} }
|
||||||
|
|
||||||
|
txns.forEach(t => {
|
||||||
|
const amt = Math.abs(t.amount);
|
||||||
|
const month = t.date.slice(0, 7);
|
||||||
|
monthly[month] = monthly[month] || { in: 0, out: 0 };
|
||||||
|
if (inflowTypes.has(t.type)) {
|
||||||
|
inflows += amt;
|
||||||
|
monthly[month].in += amt;
|
||||||
|
if (t.type === "DIVIDEND" || t.type === "COUPON") {
|
||||||
|
const k = t.asset || "—";
|
||||||
|
divByAsset[k] = (divByAsset[k] || 0) + amt;
|
||||||
|
}
|
||||||
|
} else if (outflowTypes.has(t.type)) {
|
||||||
|
outflows += amt;
|
||||||
|
monthly[month].out += amt;
|
||||||
|
if (t.type === "SPENDING") {
|
||||||
|
const k = t.category || "other";
|
||||||
|
spendByCat[k] = (spendByCat[k] || 0) + amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const net = inflows - outflows;
|
||||||
|
setKpi("in", fmtMoney(inflows));
|
||||||
|
setKpi("out", fmtMoney(outflows));
|
||||||
|
setKpi("net", fmtMoney(net), net >= 0 ? "accent" : "danger");
|
||||||
|
|
||||||
|
renderBars("spend-bars", spendByCat, "danger");
|
||||||
|
renderBars("div-bars", divByAsset, "accent");
|
||||||
|
renderMonthlyChart(monthly);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBars(id, dict, tone) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const rows = Object.entries(dict).sort((a, b) => b[1] - a[1]);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
el.innerHTML = `<div class="muted small">Нет данных.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const max = Math.max(...rows.map(r => r[1]));
|
||||||
|
rows.forEach(([k, v]) => {
|
||||||
|
el.insertAdjacentHTML("beforeend", `
|
||||||
|
<div class="bar-row">
|
||||||
|
<div class="meta">
|
||||||
|
<span class="label">${k}</span>
|
||||||
|
<span class="val mono">${fmtMoney(v)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar-track"><div class="bar-fill ${tone}" style="width:${(v / max * 100).toFixed(1)}%"></div></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMonthlyChart(monthly) {
|
||||||
|
const el = document.getElementById("monthly-chart");
|
||||||
|
const months = Object.keys(monthly).sort();
|
||||||
|
const max = Math.max(...months.map(m => Math.max(monthly[m].in, monthly[m].out)), 1);
|
||||||
|
months.forEach(m => {
|
||||||
|
const { in: i, out: o } = monthly[m];
|
||||||
|
const ih = (i / max) * 160;
|
||||||
|
const oh = (o / max) * 160;
|
||||||
|
el.insertAdjacentHTML("beforeend", `
|
||||||
|
<div class="bucket">
|
||||||
|
<div class="stack">
|
||||||
|
<div class="bar accent" style="height:${ih}px" title="In ${fmtMoney(i)}"></div>
|
||||||
|
<div class="bar danger" style="height:${oh}px" title="Out ${fmtMoney(o)}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="month">${m.slice(2)}</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Upload ----------
|
||||||
|
function renderUpload() {
|
||||||
|
mount("upload");
|
||||||
|
|
||||||
|
document.getElementById("btn-load-sample").onclick = () => {
|
||||||
|
loadData();
|
||||||
|
showUploadResult({
|
||||||
|
broker: DATA.meta.broker,
|
||||||
|
rows_parsed: DATA.transactions.length,
|
||||||
|
rows_imported: DATA.transactions.length,
|
||||||
|
rows_skipped: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById("btn-reset").onclick = () => {
|
||||||
|
resetData();
|
||||||
|
document.getElementById("upload-result").innerHTML =
|
||||||
|
`<div class="toast muted">Данные сброшены.</div>`;
|
||||||
|
};
|
||||||
|
document.getElementById("file-input").addEventListener("change", (e) => {
|
||||||
|
const f = e.target.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
// MVP-демо: настоящий парсинг XLSX живёт в backend.
|
||||||
|
// Здесь мы просто грузим mock-данные при любом файле.
|
||||||
|
loadData();
|
||||||
|
showUploadResult({
|
||||||
|
broker: DATA.meta.broker,
|
||||||
|
rows_parsed: DATA.transactions.length,
|
||||||
|
rows_imported: DATA.transactions.length,
|
||||||
|
rows_skipped: 0,
|
||||||
|
filename: f.name,
|
||||||
|
size_kb: Math.round(f.size / 1024)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUploadResult(r) {
|
||||||
|
const html = `
|
||||||
|
<div class="toast ok">
|
||||||
|
<strong class="accent">Import OK</strong><br/>
|
||||||
|
Detected broker: <strong>${r.broker}</strong><br/>
|
||||||
|
Rows parsed: ${r.rows_parsed} · Imported: <strong class="accent">${r.rows_imported}</strong> · Skipped (dupes): ${r.rows_skipped}
|
||||||
|
${r.filename ? `<br/><span class="muted small">File: ${r.filename} (${r.size_kb} KB)</span>` : ""}
|
||||||
|
<br/><br/>
|
||||||
|
Открой <a href="#dashboard" data-route="dashboard" class="accent">Dashboard</a>,
|
||||||
|
чтобы посмотреть метрики.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById("upload-result").innerHTML = html;
|
||||||
|
document
|
||||||
|
.querySelector("#upload-result a[data-route]")
|
||||||
|
.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("dashboard");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Router ----------
|
||||||
|
function navigate(route) {
|
||||||
|
currentRoute = route;
|
||||||
|
document.querySelectorAll("#nav button").forEach((b) =>
|
||||||
|
b.classList.toggle("active", b.dataset.route === route)
|
||||||
|
);
|
||||||
|
location.hash = route;
|
||||||
|
if (route === "dashboard") renderDashboard();
|
||||||
|
else if (route === "portfolio") renderPortfolio();
|
||||||
|
else if (route === "transactions") renderTransactions();
|
||||||
|
else if (route === "cashflow") renderCashflow();
|
||||||
|
else if (route === "upload") renderUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("nav").addEventListener("click", (e) => {
|
||||||
|
const r = e.target.dataset.route;
|
||||||
|
if (r) navigate(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", () => {
|
||||||
|
const r = location.hash.replace("#", "") || "dashboard";
|
||||||
|
if (r !== currentRoute) navigate(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Boot
|
||||||
|
const startRoute = location.hash.replace("#", "") || "dashboard";
|
||||||
|
navigate(startRoute);
|
||||||
|
})();
|
||||||
3
backend/.env.example
Normal file
3
backend/.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL=sqlite:///./atlas.db
|
||||||
|
CORS_ORIGINS=["http://localhost:3000"]
|
||||||
|
DEFAULT_USER_EMAIL=demo@atlas.local
|
||||||
7
backend/.gitignore
vendored
Normal file
7
backend/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
sample_broker_statement.xlsx
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
298
backend/app/analytics.py
Normal file
298
backend/app/analytics.py
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
"""Portfolio analytics engine.
|
||||||
|
|
||||||
|
Implements:
|
||||||
|
- position aggregation (avg cost, qty)
|
||||||
|
- TWR (Modified Dietz approximation) for YTD / since-inception
|
||||||
|
- IRR via Newton iteration on cash flows
|
||||||
|
- cash balance from deposits/withdrawals/dividends/spending
|
||||||
|
- allocation by asset
|
||||||
|
- spending + dividend aggregation
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .models import Transaction, TransactionType
|
||||||
|
|
||||||
|
|
||||||
|
def _d(x) -> Decimal:
|
||||||
|
return x if isinstance(x, Decimal) else Decimal(str(x))
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_positions(txns: Iterable[Transaction]) -> dict[str, dict]:
|
||||||
|
"""Build current positions using moving average cost."""
|
||||||
|
book: dict[str, dict] = {}
|
||||||
|
for t in sorted(txns, key=lambda x: x.occurred_at):
|
||||||
|
if not t.asset:
|
||||||
|
continue
|
||||||
|
pos = book.setdefault(t.asset, {"quantity": Decimal(0), "cost": Decimal(0), "realized_pnl": Decimal(0)})
|
||||||
|
qty = _d(t.quantity)
|
||||||
|
amt = abs(_d(t.amount))
|
||||||
|
|
||||||
|
if t.transaction_type == TransactionType.BUY and qty > 0:
|
||||||
|
pos["cost"] += amt
|
||||||
|
pos["quantity"] += qty
|
||||||
|
elif t.transaction_type == TransactionType.SELL and qty > 0:
|
||||||
|
if pos["quantity"] > 0:
|
||||||
|
avg = pos["cost"] / pos["quantity"]
|
||||||
|
sell_cost = avg * qty
|
||||||
|
proceeds = amt
|
||||||
|
pos["realized_pnl"] += proceeds - sell_cost
|
||||||
|
pos["cost"] -= sell_cost
|
||||||
|
pos["quantity"] -= qty
|
||||||
|
if pos["quantity"] <= 0:
|
||||||
|
pos["quantity"] = Decimal(0)
|
||||||
|
pos["cost"] = Decimal(0)
|
||||||
|
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for asset, p in book.items():
|
||||||
|
if p["quantity"] <= 0:
|
||||||
|
continue
|
||||||
|
avg_cost = p["cost"] / p["quantity"] if p["quantity"] > 0 else Decimal(0)
|
||||||
|
out[asset] = {
|
||||||
|
"quantity": p["quantity"],
|
||||||
|
"avg_cost": avg_cost,
|
||||||
|
"cost_basis": p["cost"],
|
||||||
|
"realized_pnl": p["realized_pnl"],
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cash_balance(txns: Iterable[Transaction]) -> Decimal:
|
||||||
|
bal = Decimal(0)
|
||||||
|
for t in txns:
|
||||||
|
a = _d(t.amount)
|
||||||
|
ttype = t.transaction_type
|
||||||
|
if ttype in (TransactionType.DEPOSIT,):
|
||||||
|
bal += abs(a)
|
||||||
|
elif ttype in (TransactionType.WITHDRAWAL, TransactionType.SPENDING, TransactionType.FEE):
|
||||||
|
bal -= abs(a)
|
||||||
|
elif ttype in (TransactionType.DIVIDEND, TransactionType.COUPON):
|
||||||
|
bal += abs(a)
|
||||||
|
elif ttype == TransactionType.BUY:
|
||||||
|
bal -= abs(a) + _d(t.fee)
|
||||||
|
elif ttype == TransactionType.SELL:
|
||||||
|
bal += abs(a) - _d(t.fee)
|
||||||
|
return bal
|
||||||
|
|
||||||
|
|
||||||
|
def total_realized_pnl(positions: dict[str, dict]) -> Decimal:
|
||||||
|
return sum((p["realized_pnl"] for p in positions.values()), Decimal(0))
|
||||||
|
|
||||||
|
|
||||||
|
def market_value(positions: dict[str, dict], prices: dict[str, Decimal]) -> dict[str, Decimal]:
|
||||||
|
mv: dict[str, Decimal] = {}
|
||||||
|
for asset, p in positions.items():
|
||||||
|
price = prices.get(asset, p["avg_cost"])
|
||||||
|
mv[asset] = p["quantity"] * price
|
||||||
|
return mv
|
||||||
|
|
||||||
|
|
||||||
|
def allocation(market_values: dict[str, Decimal]) -> dict[str, float]:
|
||||||
|
total = sum(market_values.values(), Decimal(0))
|
||||||
|
if total <= 0:
|
||||||
|
return {}
|
||||||
|
return {asset: float(v / total) for asset, v in market_values.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cashflows(txns: list[Transaction], start: datetime | None = None) -> list[tuple[datetime, Decimal]]:
|
||||||
|
"""External cash flows: deposit positive, withdrawal negative.
|
||||||
|
Used for TWR + IRR computation.
|
||||||
|
"""
|
||||||
|
flows: list[tuple[datetime, Decimal]] = []
|
||||||
|
for t in txns:
|
||||||
|
if start and t.occurred_at < start:
|
||||||
|
continue
|
||||||
|
a = abs(_d(t.amount))
|
||||||
|
if t.transaction_type == TransactionType.DEPOSIT:
|
||||||
|
flows.append((t.occurred_at, a))
|
||||||
|
elif t.transaction_type == TransactionType.WITHDRAWAL:
|
||||||
|
flows.append((t.occurred_at, -a))
|
||||||
|
return flows
|
||||||
|
|
||||||
|
|
||||||
|
def modified_dietz(begin_value: Decimal, end_value: Decimal, cashflows: list[tuple[datetime, Decimal]],
|
||||||
|
start: datetime, end: datetime) -> float | None:
|
||||||
|
"""TWR approximation via Modified Dietz."""
|
||||||
|
period_days = (end - start).days
|
||||||
|
if period_days <= 0:
|
||||||
|
return None
|
||||||
|
weighted_cf = Decimal(0)
|
||||||
|
total_cf = Decimal(0)
|
||||||
|
for dt, cf in cashflows:
|
||||||
|
if dt < start or dt > end:
|
||||||
|
continue
|
||||||
|
w = Decimal((end - dt).days) / Decimal(period_days)
|
||||||
|
weighted_cf += cf * w
|
||||||
|
total_cf += cf
|
||||||
|
denom = begin_value + weighted_cf
|
||||||
|
if denom <= 0:
|
||||||
|
return None
|
||||||
|
return float((end_value - begin_value - total_cf) / denom)
|
||||||
|
|
||||||
|
|
||||||
|
def irr(cashflows: list[tuple[datetime, Decimal]], final_value: Decimal, final_dt: datetime) -> float | None:
|
||||||
|
"""Newton-method IRR on dated cash flows. Returns annualized rate."""
|
||||||
|
if not cashflows:
|
||||||
|
return None
|
||||||
|
flows = cashflows + [(final_dt, final_value)]
|
||||||
|
flows = [(dt, float(cf) * (-1 if i < len(cashflows) else 1)) for i, (dt, cf) in enumerate(flows)]
|
||||||
|
base = flows[0][0]
|
||||||
|
times = [(dt - base).days / 365.0 for dt, _ in flows]
|
||||||
|
amounts = [cf if i > 0 else -abs(cf) for i, (_, cf) in enumerate(flows)]
|
||||||
|
|
||||||
|
def npv(rate: float) -> float:
|
||||||
|
return sum(a / ((1 + rate) ** t) for a, t in zip(amounts, times))
|
||||||
|
|
||||||
|
rate = 0.1
|
||||||
|
for _ in range(60):
|
||||||
|
f = npv(rate)
|
||||||
|
d = sum(-t * a / ((1 + rate) ** (t + 1)) for a, t in zip(amounts, times))
|
||||||
|
if abs(d) < 1e-12:
|
||||||
|
break
|
||||||
|
new_rate = rate - f / d
|
||||||
|
if abs(new_rate - rate) < 1e-7:
|
||||||
|
rate = new_rate
|
||||||
|
break
|
||||||
|
rate = new_rate
|
||||||
|
if rate <= -0.999:
|
||||||
|
return None
|
||||||
|
if rate < -0.99 or rate > 10:
|
||||||
|
return None
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def spending_summary(txns: list[Transaction], year: int | None = None) -> dict:
|
||||||
|
by_cat: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
monthly: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
total = Decimal(0)
|
||||||
|
for t in txns:
|
||||||
|
if t.transaction_type != TransactionType.SPENDING:
|
||||||
|
continue
|
||||||
|
if year and t.occurred_at.year != year:
|
||||||
|
continue
|
||||||
|
amt = abs(_d(t.amount))
|
||||||
|
cat = t.category or "other"
|
||||||
|
by_cat[cat] += amt
|
||||||
|
monthly[t.occurred_at.strftime("%Y-%m")] += amt
|
||||||
|
total += amt
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"by_category": {k: v for k, v in by_cat.items()},
|
||||||
|
"monthly": [{"month": m, "amount": float(v)} for m, v in sorted(monthly.items())],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dividend_summary(txns: list[Transaction], year: int | None = None) -> dict:
|
||||||
|
by_asset: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
by_month: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
total = Decimal(0)
|
||||||
|
for t in txns:
|
||||||
|
if t.transaction_type not in (TransactionType.DIVIDEND, TransactionType.COUPON):
|
||||||
|
continue
|
||||||
|
if year and t.occurred_at.year != year:
|
||||||
|
continue
|
||||||
|
amt = abs(_d(t.amount))
|
||||||
|
by_asset[t.asset or "—"] += amt
|
||||||
|
by_month[t.occurred_at.strftime("%Y-%m")] += amt
|
||||||
|
total += amt
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"by_asset": {k: v for k, v in by_asset.items()},
|
||||||
|
"by_month": [{"month": m, "amount": float(v)} for m, v in sorted(by_month.items())],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cashflow_summary(txns: list[Transaction], year: int | None = None) -> dict:
|
||||||
|
inflows = Decimal(0)
|
||||||
|
outflows = Decimal(0)
|
||||||
|
by_cat: dict[str, Decimal] = defaultdict(Decimal)
|
||||||
|
monthly: dict[str, dict[str, Decimal]] = defaultdict(lambda: {"in": Decimal(0), "out": Decimal(0)})
|
||||||
|
|
||||||
|
inflow_types = {TransactionType.DEPOSIT, TransactionType.DIVIDEND, TransactionType.COUPON}
|
||||||
|
outflow_types = {TransactionType.WITHDRAWAL, TransactionType.SPENDING, TransactionType.FEE}
|
||||||
|
|
||||||
|
for t in txns:
|
||||||
|
if year and t.occurred_at.year != year:
|
||||||
|
continue
|
||||||
|
amt = abs(_d(t.amount))
|
||||||
|
month = t.occurred_at.strftime("%Y-%m")
|
||||||
|
if t.transaction_type in inflow_types:
|
||||||
|
inflows += amt
|
||||||
|
by_cat[f"in:{t.transaction_type.value}"] += amt
|
||||||
|
monthly[month]["in"] += amt
|
||||||
|
elif t.transaction_type in outflow_types:
|
||||||
|
outflows += amt
|
||||||
|
label = t.category or t.transaction_type.value
|
||||||
|
by_cat[f"out:{label}"] += amt
|
||||||
|
monthly[month]["out"] += amt
|
||||||
|
|
||||||
|
return {
|
||||||
|
"inflows": inflows,
|
||||||
|
"outflows": outflows,
|
||||||
|
"net": inflows - outflows,
|
||||||
|
"by_category": {k: v for k, v in by_cat.items()},
|
||||||
|
"monthly": [
|
||||||
|
{"month": m, "inflow": float(v["in"]), "outflow": float(v["out"])}
|
||||||
|
for m, v in sorted(monthly.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def portfolio_metrics(txns: list[Transaction], prices: dict[str, Decimal]) -> dict:
|
||||||
|
if not txns:
|
||||||
|
return {
|
||||||
|
"positions": {},
|
||||||
|
"market_values": {},
|
||||||
|
"allocation": {},
|
||||||
|
"total_market_value": Decimal(0),
|
||||||
|
"total_cost_basis": Decimal(0),
|
||||||
|
"unrealized_pnl": Decimal(0),
|
||||||
|
"unrealized_pnl_pct": 0.0,
|
||||||
|
"realized_pnl": Decimal(0),
|
||||||
|
"cash_balance": Decimal(0),
|
||||||
|
"twr_ytd": None,
|
||||||
|
"twr_since_inception": None,
|
||||||
|
"irr_since_inception": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
txns_sorted = sorted(txns, key=lambda t: t.occurred_at)
|
||||||
|
positions = aggregate_positions(txns_sorted)
|
||||||
|
mv = market_value(positions, prices)
|
||||||
|
alloc = allocation(mv)
|
||||||
|
total_mv = sum(mv.values(), Decimal(0))
|
||||||
|
total_cost = sum((p["cost_basis"] for p in positions.values()), Decimal(0))
|
||||||
|
unrealized = total_mv - total_cost
|
||||||
|
unrealized_pct = float(unrealized / total_cost) if total_cost > 0 else 0.0
|
||||||
|
realized = total_realized_pnl(positions)
|
||||||
|
cash = cash_balance(txns_sorted)
|
||||||
|
|
||||||
|
inception = txns_sorted[0].occurred_at
|
||||||
|
now = datetime.utcnow()
|
||||||
|
ytd_start = datetime(now.year, 1, 1)
|
||||||
|
|
||||||
|
end_value = total_mv + cash
|
||||||
|
cf_all = _build_cashflows(txns_sorted)
|
||||||
|
cf_ytd = _build_cashflows(txns_sorted, start=ytd_start)
|
||||||
|
|
||||||
|
twr_inception = modified_dietz(Decimal(0), end_value, cf_all, inception, now)
|
||||||
|
twr_ytd = modified_dietz(Decimal(0), end_value, cf_ytd, ytd_start, now) if cf_ytd else None
|
||||||
|
irr_val = irr(cf_all, end_value, now)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"positions": positions,
|
||||||
|
"market_values": mv,
|
||||||
|
"allocation": alloc,
|
||||||
|
"total_market_value": total_mv,
|
||||||
|
"total_cost_basis": total_cost,
|
||||||
|
"unrealized_pnl": unrealized,
|
||||||
|
"unrealized_pnl_pct": unrealized_pct,
|
||||||
|
"realized_pnl": realized,
|
||||||
|
"cash_balance": cash,
|
||||||
|
"twr_ytd": twr_ytd,
|
||||||
|
"twr_since_inception": twr_inception,
|
||||||
|
"irr_since_inception": irr_val,
|
||||||
|
}
|
||||||
64
backend/app/classifier.py
Normal file
64
backend/app/classifier.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""Rule-based transaction classifier.
|
||||||
|
|
||||||
|
Maps raw broker/bank rows to canonical TransactionType. Operates on
|
||||||
|
normalized lower-case description + amount sign + quantity presence.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from .models import TransactionType
|
||||||
|
|
||||||
|
RULES: list[tuple[tuple[str, ...], TransactionType]] = [
|
||||||
|
(("dividend", "div ", "div.", "cash div", "qualified div"), TransactionType.DIVIDEND),
|
||||||
|
(("coupon", "bond interest", "interest payment"), TransactionType.COUPON),
|
||||||
|
(("buy", "purchase securities", "купля", "покупка ц/б"), TransactionType.BUY),
|
||||||
|
(("sell", "sale of securities", "продажа ц/б"), TransactionType.SELL),
|
||||||
|
(("deposit", "wire in", "funding", "ввод средств"), TransactionType.DEPOSIT),
|
||||||
|
(("withdraw", "wire out", "cash withdrawal", "вывод средств"), TransactionType.WITHDRAWAL),
|
||||||
|
(("card purchase", "pos ", "merchant", "card "), TransactionType.SPENDING),
|
||||||
|
(("transfer", "перевод", "internal mvmt"), TransactionType.TRANSFER),
|
||||||
|
(("fx", "currency conversion", "конверсия"), TransactionType.FX),
|
||||||
|
(("fee", "commission", "комиссия"), TransactionType.FEE),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def classify(description: str | None, raw_type: str | None, amount: Decimal, quantity: Decimal) -> TransactionType:
|
||||||
|
haystack = " ".join(filter(None, [(description or ""), (raw_type or "")])).lower().strip()
|
||||||
|
|
||||||
|
for keywords, ttype in RULES:
|
||||||
|
if any(kw in haystack for kw in keywords):
|
||||||
|
return ttype
|
||||||
|
|
||||||
|
if quantity and quantity > 0 and amount < 0:
|
||||||
|
return TransactionType.BUY
|
||||||
|
if quantity and quantity > 0 and amount > 0:
|
||||||
|
return TransactionType.SELL
|
||||||
|
if not quantity and amount > 0:
|
||||||
|
return TransactionType.DEPOSIT
|
||||||
|
if not quantity and amount < 0:
|
||||||
|
return TransactionType.SPENDING
|
||||||
|
|
||||||
|
return TransactionType.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
SPENDING_CATEGORIES: list[tuple[tuple[str, ...], str]] = [
|
||||||
|
(("uber", "lyft", "taxi", "metro", "bolt"), "transport"),
|
||||||
|
(("airline", "hotel", "booking", "airbnb", "expedia"), "travel"),
|
||||||
|
(("restaurant", "cafe", "starbucks", "mcdonald", "kfc", "dining"), "restaurants"),
|
||||||
|
(("amazon", "shop", "store", "zara", "h&m", "apple store"), "shopping"),
|
||||||
|
(("netflix", "spotify", "subscription", "icloud", "google one"), "subscriptions"),
|
||||||
|
(("pharmacy", "clinic", "hospital", "doctor", "medical"), "healthcare"),
|
||||||
|
(("school", "tuition", "course", "udemy", "coursera"), "education"),
|
||||||
|
(("gucci", "louis vuitton", "rolex", "chanel"), "luxury"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_spending_category(description: str | None) -> str | None:
|
||||||
|
if not description:
|
||||||
|
return None
|
||||||
|
text = description.lower()
|
||||||
|
for keywords, category in SPENDING_CATEGORIES:
|
||||||
|
if any(kw in text for kw in keywords):
|
||||||
|
return category
|
||||||
|
return "other"
|
||||||
13
backend/app/config.py
Normal file
13
backend/app/config.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str = "sqlite:///./atlas.db"
|
||||||
|
cors_origins: list[str] = ["http://localhost:3000"]
|
||||||
|
default_user_email: str = "demo@atlas.local"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
22
backend/app/db.py
Normal file
22
backend/app/db.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.database_url,
|
||||||
|
connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {},
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
14
backend/app/deps.py
Normal file
14
backend/app/deps.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .db import get_db
|
||||||
|
from .models import User
|
||||||
|
from .services import get_or_create_demo_user
|
||||||
|
|
||||||
|
|
||||||
|
def current_user(db: Session = Depends(get_db)) -> User:
|
||||||
|
"""MVP: single demo user. JWT auth (TRD §13) is Phase 2."""
|
||||||
|
user = get_or_create_demo_user(db, settings.default_user_email)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
35
backend/app/main.py
Normal file
35
backend/app/main.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .db import Base, engine
|
||||||
|
from .routes import accounts, cashflow, copilot, dashboard, portfolio, transactions, upload
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Atlas AI Wealth API",
|
||||||
|
version="0.1.0",
|
||||||
|
description="AI-native wealth platform — vertical slice MVP (TRD §1-§9, §12 stub).",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(upload.router)
|
||||||
|
app.include_router(accounts.router)
|
||||||
|
app.include_router(transactions.router)
|
||||||
|
app.include_router(portfolio.router)
|
||||||
|
app.include_router(cashflow.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(copilot.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "service": "atlas-ai-wealth"}
|
||||||
27
backend/app/market_data.py
Normal file
27
backend/app/market_data.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"""Market data stub.
|
||||||
|
|
||||||
|
TRD §10 calls for Yahoo/AlphaVantage/Polygon. MVP uses a stub that
|
||||||
|
returns last-known cost as price. Wire a real fetcher (yfinance) here
|
||||||
|
when network access is available.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
_FALLBACK_PRICES: dict[str, Decimal] = {
|
||||||
|
"AAPL": Decimal("212.50"),
|
||||||
|
"MSFT": Decimal("428.00"),
|
||||||
|
"NVDA": Decimal("128.40"),
|
||||||
|
"GOOGL": Decimal("178.20"),
|
||||||
|
"TSLA": Decimal("245.00"),
|
||||||
|
"SPY": Decimal("568.00"),
|
||||||
|
"QQQ": Decimal("498.00"),
|
||||||
|
"BTC": Decimal("68500.00"),
|
||||||
|
"ETH": Decimal("3450.00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_prices(assets: list[str], avg_cost_fallback: dict[str, Decimal] | None = None) -> dict[str, Decimal]:
|
||||||
|
fallback = avg_cost_fallback or {}
|
||||||
|
return {a: _FALLBACK_PRICES.get(a, fallback.get(a, Decimal(0))) for a in assets}
|
||||||
81
backend/app/models.py
Normal file
81
backend/app/models.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Numeric, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def gen_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionType(str, enum.Enum):
|
||||||
|
BUY = "BUY"
|
||||||
|
SELL = "SELL"
|
||||||
|
DIVIDEND = "DIVIDEND"
|
||||||
|
COUPON = "COUPON"
|
||||||
|
DEPOSIT = "DEPOSIT"
|
||||||
|
WITHDRAWAL = "WITHDRAWAL"
|
||||||
|
SPENDING = "SPENDING"
|
||||||
|
TRANSFER = "TRANSFER"
|
||||||
|
FX = "FX"
|
||||||
|
FEE = "FEE"
|
||||||
|
UNKNOWN = "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
accounts: Mapped[list["Account"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base):
|
||||||
|
__tablename__ = "accounts"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||||
|
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
broker_name: Mapped[str] = mapped_column(String(120))
|
||||||
|
currency: Mapped[str] = mapped_column(String(8), default="USD")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="accounts")
|
||||||
|
transactions: Mapped[list["Transaction"]] = relationship(back_populates="account", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(Base):
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||||
|
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id"), index=True)
|
||||||
|
occurred_at: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||||
|
transaction_type: Mapped[TransactionType] = mapped_column(Enum(TransactionType), default=TransactionType.UNKNOWN)
|
||||||
|
asset: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(24, 8), default=0)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(24, 8), default=0)
|
||||||
|
currency: Mapped[str] = mapped_column(String(8), default="USD")
|
||||||
|
fee: Mapped[Decimal] = mapped_column(Numeric(24, 8), default=0)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
account: Mapped[Account] = relationship(back_populates="transactions")
|
||||||
|
|
||||||
|
|
||||||
|
class Position(Base):
|
||||||
|
__tablename__ = "positions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||||
|
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id"), index=True)
|
||||||
|
asset: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(24, 8), default=0)
|
||||||
|
avg_cost: Mapped[Decimal] = mapped_column(Numeric(24, 8), default=0)
|
||||||
|
market_price: Mapped[Decimal] = mapped_column(Numeric(24, 8), default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
142
backend/app/parser.py
Normal file
142
backend/app/parser.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""Excel/CSV broker statement parser.
|
||||||
|
|
||||||
|
Handles multi-sheet XLSX with inconsistent headers. Maps free-form column
|
||||||
|
names to a canonical schema via fuzzy header matching.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
CANONICAL_HEADERS: dict[str, tuple[str, ...]] = {
|
||||||
|
"occurred_at": ("date", "datetime", "trade date", "settle date", "дата", "operation date"),
|
||||||
|
"transaction_type": ("type", "transaction type", "operation", "тип операции", "action"),
|
||||||
|
"asset": ("asset", "ticker", "symbol", "instrument", "тикер", "security"),
|
||||||
|
"quantity": ("quantity", "qty", "shares", "количество", "amount of units"),
|
||||||
|
"amount": ("amount", "value", "total", "сумма", "net amount", "cash"),
|
||||||
|
"currency": ("currency", "ccy", "валюта"),
|
||||||
|
"fee": ("fee", "commission", "комиссия", "broker fee"),
|
||||||
|
"description": ("description", "details", "narration", "memo", "комментарий", "purpose"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedRow:
|
||||||
|
occurred_at: datetime
|
||||||
|
transaction_type: str | None
|
||||||
|
asset: str | None
|
||||||
|
quantity: Decimal
|
||||||
|
amount: Decimal
|
||||||
|
currency: str
|
||||||
|
fee: Decimal
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
return str(s).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _match_header(col: str) -> str | None:
|
||||||
|
norm = _norm(col)
|
||||||
|
for canonical, aliases in CANONICAL_HEADERS.items():
|
||||||
|
if norm == canonical:
|
||||||
|
return canonical
|
||||||
|
if any(alias in norm for alias in aliases):
|
||||||
|
return canonical
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(v) -> Decimal:
|
||||||
|
if v is None or (isinstance(v, float) and pd.isna(v)):
|
||||||
|
return Decimal(0)
|
||||||
|
if isinstance(v, Decimal):
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
s = str(v).replace(" ", "").replace(",", ".").replace("\xa0", "")
|
||||||
|
if s in ("", "-", "nan"):
|
||||||
|
return Decimal(0)
|
||||||
|
return Decimal(s)
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return Decimal(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_datetime(v) -> datetime | None:
|
||||||
|
if v is None or (isinstance(v, float) and pd.isna(v)):
|
||||||
|
return None
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
return pd.to_datetime(v, dayfirst=False, errors="coerce").to_pydatetime()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_sheets(content: bytes, filename: str) -> dict[str, pd.DataFrame]:
|
||||||
|
if filename.lower().endswith(".csv"):
|
||||||
|
return {"sheet1": pd.read_csv(BytesIO(content))}
|
||||||
|
return pd.read_excel(BytesIO(content), sheet_name=None, engine="openpyxl")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_broker(sheets: dict[str, pd.DataFrame]) -> str:
|
||||||
|
blob = " ".join(s for s in sheets.keys()).lower()
|
||||||
|
for df in sheets.values():
|
||||||
|
blob += " " + " ".join(map(_norm, df.columns.astype(str)))
|
||||||
|
if "interactive brokers" in blob or "ibkr" in blob:
|
||||||
|
return "Interactive Brokers"
|
||||||
|
if "freedom" in blob:
|
||||||
|
return "Freedom Finance"
|
||||||
|
if "halyk" in blob:
|
||||||
|
return "Halyk Finance"
|
||||||
|
if "binance" in blob:
|
||||||
|
return "Binance"
|
||||||
|
if "kaspi" in blob:
|
||||||
|
return "Kaspi"
|
||||||
|
if "schwab" in blob:
|
||||||
|
return "Charles Schwab"
|
||||||
|
return "Generic Broker"
|
||||||
|
|
||||||
|
|
||||||
|
def _map_columns(df: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, str]]:
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for col in df.columns:
|
||||||
|
canonical = _match_header(str(col))
|
||||||
|
if canonical and canonical not in mapping.values():
|
||||||
|
mapping[str(col)] = canonical
|
||||||
|
renamed = df.rename(columns=mapping)
|
||||||
|
return renamed, mapping
|
||||||
|
|
||||||
|
|
||||||
|
def parse(content: bytes, filename: str) -> tuple[list[ParsedRow], str]:
|
||||||
|
sheets = _read_sheets(content, filename)
|
||||||
|
broker = detect_broker(sheets)
|
||||||
|
rows: list[ParsedRow] = []
|
||||||
|
|
||||||
|
for df in sheets.values():
|
||||||
|
if df.empty:
|
||||||
|
continue
|
||||||
|
mapped, mapping = _map_columns(df)
|
||||||
|
if "occurred_at" not in mapping.values() or "amount" not in mapping.values():
|
||||||
|
continue
|
||||||
|
for _, r in mapped.iterrows():
|
||||||
|
dt = _to_datetime(r.get("occurred_at"))
|
||||||
|
if dt is None:
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
ParsedRow(
|
||||||
|
occurred_at=dt,
|
||||||
|
transaction_type=str(r["transaction_type"]) if "transaction_type" in r else None,
|
||||||
|
asset=str(r["asset"]).strip() if "asset" in r and pd.notna(r.get("asset")) else None,
|
||||||
|
quantity=_to_decimal(r.get("quantity")),
|
||||||
|
amount=_to_decimal(r.get("amount")),
|
||||||
|
currency=str(r["currency"]).strip().upper() if "currency" in r and pd.notna(r.get("currency")) else "USD",
|
||||||
|
fee=_to_decimal(r.get("fee")),
|
||||||
|
description=str(r["description"]) if "description" in r and pd.notna(r.get("description")) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows, broker
|
||||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
14
backend/app/routes/accounts.py
Normal file
14
backend/app/routes/accounts.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import current_user
|
||||||
|
from ..models import Account, User
|
||||||
|
from ..schemas import AccountOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AccountOut])
|
||||||
|
def list_accounts(db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
return db.query(Account).filter(Account.user_id == user.id).all()
|
||||||
31
backend/app/routes/cashflow.py
Normal file
31
backend/app/routes/cashflow.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import analytics
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..schemas import CashFlowSummary, DividendSummary
|
||||||
|
from ..services import user_transactions
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cashflow", tags=["cashflow"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=CashFlowSummary)
|
||||||
|
def cashflow(year: int | None = Query(default=None), db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
txns = user_transactions(db, user)
|
||||||
|
return analytics.cashflow_summary(txns, year=year)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/spending")
|
||||||
|
def spending(year: int | None = Query(default=None), db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
txns = user_transactions(db, user)
|
||||||
|
return analytics.spending_summary(txns, year=year)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dividends", response_model=DividendSummary)
|
||||||
|
def dividends(year: int | None = Query(default=None), db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
txns = user_transactions(db, user)
|
||||||
|
return analytics.dividend_summary(txns, year=year)
|
||||||
91
backend/app/routes/copilot.py
Normal file
91
backend/app/routes/copilot.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"""AI Copilot stub.
|
||||||
|
|
||||||
|
TRD §12 RAG pipeline (retrieve portfolio + market context → LLM). MVP
|
||||||
|
returns templated insights computed from portfolio analytics. Plug in
|
||||||
|
OpenAI/Anthropic via the `generate_answer` function when keys are set.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import analytics, market_data
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..services import user_transactions
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/copilot", tags=["copilot"])
|
||||||
|
|
||||||
|
|
||||||
|
class Query(BaseModel):
|
||||||
|
question: str
|
||||||
|
|
||||||
|
|
||||||
|
class Answer(BaseModel):
|
||||||
|
answer: str
|
||||||
|
context: dict
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_pct(v: float | None) -> str:
|
||||||
|
return f"{v * 100:+.2f}%" if v is not None else "—"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_money(v: Decimal | float) -> str:
|
||||||
|
return f"${float(v):,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ask", response_model=Answer)
|
||||||
|
def ask(query: Query, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
txns = user_transactions(db, user)
|
||||||
|
pos_book = analytics.aggregate_positions(txns)
|
||||||
|
avg_costs = {a: p["avg_cost"] for a, p in pos_book.items()}
|
||||||
|
prices = market_data.get_prices(list(pos_book.keys()), avg_costs)
|
||||||
|
m = analytics.portfolio_metrics(txns, prices)
|
||||||
|
sp = analytics.spending_summary(txns)
|
||||||
|
dv = analytics.dividend_summary(txns)
|
||||||
|
|
||||||
|
q = query.question.lower()
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
if "perform" in q or "доходность" in q or "return" in q:
|
||||||
|
parts.append(
|
||||||
|
f"Portfolio TWR YTD: {_fmt_pct(m['twr_ytd'])}. "
|
||||||
|
f"Since inception: {_fmt_pct(m['twr_since_inception'])}. "
|
||||||
|
f"IRR: {_fmt_pct(m['irr_since_inception'])}. "
|
||||||
|
f"Unrealized PnL: {_fmt_money(m['unrealized_pnl'])} ({_fmt_pct(m['unrealized_pnl_pct'])})."
|
||||||
|
)
|
||||||
|
if "spend" in q or "трат" in q or "expense" in q:
|
||||||
|
top_cat = max(sp["by_category"].items(), key=lambda kv: kv[1], default=("—", Decimal(0)))
|
||||||
|
parts.append(
|
||||||
|
f"YTD spending: {_fmt_money(sp['total'])}. Top category: {top_cat[0]} ({_fmt_money(top_cat[1])})."
|
||||||
|
)
|
||||||
|
if "divid" in q or "passive" in q or "coupon" in q or "пассив" in q:
|
||||||
|
cov = float(dv["total"] / sp["total"]) if sp["total"] > 0 else 0.0
|
||||||
|
parts.append(
|
||||||
|
f"Passive income YTD: {_fmt_money(dv['total'])}. Covers {cov * 100:.1f}% of spending."
|
||||||
|
)
|
||||||
|
if "risk" in q or "concentr" in q or "риск" in q:
|
||||||
|
if m["allocation"]:
|
||||||
|
top_asset, share = max(m["allocation"].items(), key=lambda kv: kv[1])
|
||||||
|
parts.append(f"Concentration risk: {top_asset} = {share * 100:.1f}% of portfolio.")
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
parts.append(
|
||||||
|
f"Net worth {_fmt_money(m['total_market_value'] + m['cash_balance'])}. "
|
||||||
|
f"Portfolio {_fmt_money(m['total_market_value'])}, cash {_fmt_money(m['cash_balance'])}. "
|
||||||
|
f"TWR YTD {_fmt_pct(m['twr_ytd'])}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return Answer(
|
||||||
|
answer=" ".join(parts),
|
||||||
|
context={
|
||||||
|
"twr_ytd": m["twr_ytd"],
|
||||||
|
"spending_ytd": float(sp["total"]),
|
||||||
|
"dividends_ytd": float(dv["total"]),
|
||||||
|
"positions": len(m["positions"]),
|
||||||
|
},
|
||||||
|
)
|
||||||
58
backend/app/routes/dashboard.py
Normal file
58
backend/app/routes/dashboard.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import analytics, market_data
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..schemas import DashboardResponse, PositionOut
|
||||||
|
from ..services import user_transactions
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DashboardResponse)
|
||||||
|
def get_dashboard(db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
txns = user_transactions(db, user)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
year = now.year
|
||||||
|
|
||||||
|
pos_book = analytics.aggregate_positions(txns)
|
||||||
|
avg_costs = {a: p["avg_cost"] for a, p in pos_book.items()}
|
||||||
|
prices = market_data.get_prices(list(pos_book.keys()), avg_costs)
|
||||||
|
|
||||||
|
metrics = analytics.portfolio_metrics(txns, prices)
|
||||||
|
spend = analytics.spending_summary(txns, year=year)
|
||||||
|
spend_mtd = Decimal(0)
|
||||||
|
mtd_key = now.strftime("%Y-%m")
|
||||||
|
for m in spend["monthly"]:
|
||||||
|
if m["month"] == mtd_key:
|
||||||
|
spend_mtd = Decimal(str(m["amount"]))
|
||||||
|
break
|
||||||
|
divs = analytics.dividend_summary(txns, year=year)
|
||||||
|
|
||||||
|
top: list[PositionOut] = []
|
||||||
|
for a, p in metrics["positions"].items():
|
||||||
|
price = prices.get(a, p["avg_cost"])
|
||||||
|
mv = p["quantity"] * price
|
||||||
|
pnl = mv - p["cost_basis"]
|
||||||
|
pct = float(pnl / p["cost_basis"]) if p["cost_basis"] > 0 else 0.0
|
||||||
|
top.append(PositionOut(
|
||||||
|
asset=a, quantity=p["quantity"], avg_cost=p["avg_cost"],
|
||||||
|
market_price=price, market_value=mv, unrealized_pnl=pnl, unrealized_pnl_pct=pct,
|
||||||
|
))
|
||||||
|
top.sort(key=lambda x: float(x.market_value), reverse=True)
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
net_worth=metrics["total_market_value"] + metrics["cash_balance"],
|
||||||
|
portfolio_value=metrics["total_market_value"],
|
||||||
|
cash_balance=metrics["cash_balance"],
|
||||||
|
spending_mtd=spend_mtd,
|
||||||
|
spending_ytd=spend["total"],
|
||||||
|
passive_income_ytd=divs["total"],
|
||||||
|
performance_ytd=metrics["twr_ytd"],
|
||||||
|
top_positions=top[:5],
|
||||||
|
)
|
||||||
56
backend/app/routes/portfolio.py
Normal file
56
backend/app/routes/portfolio.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import analytics, market_data
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..schemas import PortfolioMetrics, PositionOut
|
||||||
|
from ..services import user_transactions
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/portfolio", tags=["portfolio"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_position_out(asset: str, p: dict, price: Decimal) -> PositionOut:
|
||||||
|
qty = p["quantity"]
|
||||||
|
mv = qty * price
|
||||||
|
pnl = mv - p["cost_basis"]
|
||||||
|
pct = float(pnl / p["cost_basis"]) if p["cost_basis"] > 0 else 0.0
|
||||||
|
return PositionOut(
|
||||||
|
asset=asset,
|
||||||
|
quantity=qty,
|
||||||
|
avg_cost=p["avg_cost"],
|
||||||
|
market_price=price,
|
||||||
|
market_value=mv,
|
||||||
|
unrealized_pnl=pnl,
|
||||||
|
unrealized_pnl_pct=pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics", response_model=PortfolioMetrics)
|
||||||
|
def get_metrics(db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||||
|
txns = user_transactions(db, user)
|
||||||
|
pos_book = analytics.aggregate_positions(txns)
|
||||||
|
avg_costs = {a: p["avg_cost"] for a, p in pos_book.items()}
|
||||||
|
prices = market_data.get_prices(list(pos_book.keys()), avg_costs)
|
||||||
|
|
||||||
|
metrics = analytics.portfolio_metrics(txns, prices)
|
||||||
|
|
||||||
|
positions = [_to_position_out(a, p, prices.get(a, p["avg_cost"])) for a, p in metrics["positions"].items()]
|
||||||
|
positions.sort(key=lambda x: float(x.market_value), reverse=True)
|
||||||
|
|
||||||
|
return PortfolioMetrics(
|
||||||
|
total_market_value=metrics["total_market_value"],
|
||||||
|
total_cost_basis=metrics["total_cost_basis"],
|
||||||
|
unrealized_pnl=metrics["unrealized_pnl"],
|
||||||
|
unrealized_pnl_pct=metrics["unrealized_pnl_pct"],
|
||||||
|
realized_pnl=metrics["realized_pnl"],
|
||||||
|
cash_balance=metrics["cash_balance"],
|
||||||
|
twr_ytd=metrics["twr_ytd"],
|
||||||
|
twr_since_inception=metrics["twr_since_inception"],
|
||||||
|
irr_since_inception=metrics["irr_since_inception"],
|
||||||
|
positions=positions,
|
||||||
|
allocation=metrics["allocation"],
|
||||||
|
)
|
||||||
30
backend/app/routes/transactions.py
Normal file
30
backend/app/routes/transactions.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import current_user
|
||||||
|
from ..models import TransactionType, User
|
||||||
|
from ..schemas import TransactionOut
|
||||||
|
from ..services import user_transactions
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TransactionOut])
|
||||||
|
def list_transactions(
|
||||||
|
transaction_type: TransactionType | None = Query(default=None),
|
||||||
|
asset: str | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, description="Free-text search in description"),
|
||||||
|
limit: int = Query(default=500, le=5000),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
):
|
||||||
|
txns = user_transactions(db, user)
|
||||||
|
if transaction_type:
|
||||||
|
txns = [t for t in txns if t.transaction_type == transaction_type]
|
||||||
|
if asset:
|
||||||
|
txns = [t for t in txns if (t.asset or "").upper() == asset.upper()]
|
||||||
|
if q:
|
||||||
|
needle = q.lower()
|
||||||
|
txns = [t for t in txns if needle in (t.description or "").lower()]
|
||||||
|
return txns[:limit]
|
||||||
29
backend/app/routes/upload.py
Normal file
29
backend/app/routes/upload.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..schemas import UploadResult
|
||||||
|
from ..services import import_file
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/upload", tags=["upload"])
|
||||||
|
|
||||||
|
ALLOWED_EXT = (".xlsx", ".xls", ".csv")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UploadResult)
|
||||||
|
async def upload_statement(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
):
|
||||||
|
if not file.filename or not file.filename.lower().endswith(ALLOWED_EXT):
|
||||||
|
raise HTTPException(400, f"Unsupported file type. Allowed: {ALLOWED_EXT}")
|
||||||
|
content = await file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(400, "Empty file")
|
||||||
|
try:
|
||||||
|
return import_file(db, user, content, file.filename)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(422, f"Parse failed: {e}") from e
|
||||||
89
backend/app/schemas.py
Normal file
89
backend/app/schemas.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from .models import TransactionType
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
account_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
transaction_type: TransactionType
|
||||||
|
asset: str | None
|
||||||
|
quantity: Decimal
|
||||||
|
amount: Decimal
|
||||||
|
currency: str
|
||||||
|
fee: Decimal
|
||||||
|
category: str | None
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PositionOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
asset: str
|
||||||
|
quantity: Decimal
|
||||||
|
avg_cost: Decimal
|
||||||
|
market_price: Decimal
|
||||||
|
market_value: Decimal
|
||||||
|
unrealized_pnl: Decimal
|
||||||
|
unrealized_pnl_pct: float
|
||||||
|
|
||||||
|
|
||||||
|
class AccountOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
broker_name: str
|
||||||
|
currency: str
|
||||||
|
|
||||||
|
|
||||||
|
class UploadResult(BaseModel):
|
||||||
|
account_id: str
|
||||||
|
rows_parsed: int
|
||||||
|
rows_imported: int
|
||||||
|
rows_skipped: int
|
||||||
|
detected_broker: str
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMetrics(BaseModel):
|
||||||
|
total_market_value: Decimal
|
||||||
|
total_cost_basis: Decimal
|
||||||
|
unrealized_pnl: Decimal
|
||||||
|
unrealized_pnl_pct: float
|
||||||
|
realized_pnl: Decimal
|
||||||
|
cash_balance: Decimal
|
||||||
|
twr_ytd: float | None
|
||||||
|
twr_since_inception: float | None
|
||||||
|
irr_since_inception: float | None
|
||||||
|
positions: list[PositionOut]
|
||||||
|
allocation: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
class CashFlowSummary(BaseModel):
|
||||||
|
inflows: Decimal
|
||||||
|
outflows: Decimal
|
||||||
|
net: Decimal
|
||||||
|
by_category: dict[str, Decimal]
|
||||||
|
monthly: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class DividendSummary(BaseModel):
|
||||||
|
total: Decimal
|
||||||
|
by_asset: dict[str, Decimal]
|
||||||
|
by_month: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardResponse(BaseModel):
|
||||||
|
net_worth: Decimal
|
||||||
|
portfolio_value: Decimal
|
||||||
|
cash_balance: Decimal
|
||||||
|
spending_mtd: Decimal
|
||||||
|
spending_ytd: Decimal
|
||||||
|
passive_income_ytd: Decimal
|
||||||
|
performance_ytd: float | None
|
||||||
|
top_positions: list[PositionOut]
|
||||||
94
backend/app/services.py
Normal file
94
backend/app/services.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""Service layer: stitches parser + classifier + persistence."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .classifier import classify, classify_spending_category
|
||||||
|
from .models import Account, Transaction, TransactionType, User
|
||||||
|
from .parser import ParsedRow, parse
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_demo_user(db: Session, email: str) -> User:
|
||||||
|
user = db.query(User).filter(User.email == email).first()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
user = User(email=email)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_account(db: Session, user: User, broker: str, currency: str = "USD") -> Account:
|
||||||
|
acc = (
|
||||||
|
db.query(Account)
|
||||||
|
.filter(Account.user_id == user.id, Account.broker_name == broker)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if acc:
|
||||||
|
return acc
|
||||||
|
acc = Account(user_id=user.id, broker_name=broker, currency=currency)
|
||||||
|
db.add(acc)
|
||||||
|
db.flush()
|
||||||
|
return acc
|
||||||
|
|
||||||
|
|
||||||
|
def import_file(db: Session, user: User, content: bytes, filename: str) -> dict:
|
||||||
|
rows, broker = parse(content, filename)
|
||||||
|
account = get_or_create_account(db, user, broker)
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
for r in rows:
|
||||||
|
ttype = classify(r.description, r.transaction_type, r.amount, r.quantity)
|
||||||
|
category = classify_spending_category(r.description) if ttype == TransactionType.SPENDING else None
|
||||||
|
|
||||||
|
exists = (
|
||||||
|
db.query(Transaction)
|
||||||
|
.filter(
|
||||||
|
Transaction.account_id == account.id,
|
||||||
|
Transaction.occurred_at == r.occurred_at,
|
||||||
|
Transaction.amount == r.amount,
|
||||||
|
Transaction.asset == r.asset,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=account.id,
|
||||||
|
occurred_at=r.occurred_at,
|
||||||
|
transaction_type=ttype,
|
||||||
|
asset=r.asset,
|
||||||
|
quantity=r.quantity,
|
||||||
|
amount=r.amount,
|
||||||
|
currency=r.currency,
|
||||||
|
fee=r.fee,
|
||||||
|
category=category,
|
||||||
|
description=r.description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"account_id": account.id,
|
||||||
|
"rows_parsed": len(rows),
|
||||||
|
"rows_imported": imported,
|
||||||
|
"rows_skipped": skipped,
|
||||||
|
"detected_broker": broker,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def user_transactions(db: Session, user: User) -> list[Transaction]:
|
||||||
|
return (
|
||||||
|
db.query(Transaction)
|
||||||
|
.join(Account, Account.id == Transaction.account_id)
|
||||||
|
.filter(Account.user_id == user.id)
|
||||||
|
.order_by(Transaction.occurred_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.32.0
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.6.1
|
||||||
|
python-multipart==0.0.12
|
||||||
|
pandas==2.2.3
|
||||||
|
openpyxl==3.1.5
|
||||||
|
numpy==2.1.3
|
||||||
|
python-dateutil==2.9.0
|
||||||
69
backend/scripts/generate_sample.py
Normal file
69
backend/scripts/generate_sample.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Generate a sample broker statement XLSX with mixed transaction types.
|
||||||
|
|
||||||
|
Run: python -m scripts.generate_sample
|
||||||
|
Produces: ./sample_broker_statement.xlsx
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "sample_broker_statement.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
base = datetime(2024, 1, 5)
|
||||||
|
|
||||||
|
trades = [
|
||||||
|
# date, type, asset, qty, amount, ccy, fee, description
|
||||||
|
(base, "BUY", "AAPL", 50, -9200.00, "USD", 1.00, "Interactive Brokers — BUY AAPL"),
|
||||||
|
(base + timedelta(days=10), "BUY", "MSFT", 20, -7400.00, "USD", 1.00, "BUY MSFT"),
|
||||||
|
(base + timedelta(days=30), "BUY", "NVDA", 30, -2700.00, "USD", 1.00, "BUY NVDA"),
|
||||||
|
(base + timedelta(days=45), "DIVIDEND", "AAPL", 0, 36.00, "USD", 0, "Cash DIVIDEND AAPL"),
|
||||||
|
(base + timedelta(days=70), "BUY", "SPY", 15, -7200.00, "USD", 1.00, "BUY SPY"),
|
||||||
|
(base + timedelta(days=90), "DIVIDEND", "MSFT", 0, 18.00, "USD", 0, "Qualified DIV MSFT"),
|
||||||
|
(base + timedelta(days=120), "SELL", "AAPL", 10, 2100.00, "USD", 1.00, "SELL AAPL"),
|
||||||
|
(base + timedelta(days=140), "COUPON", "UST10Y", 0, 220.00, "USD", 0, "Bond interest payment"),
|
||||||
|
(base + timedelta(days=180), "BUY", "GOOGL", 8, -1320.00, "USD", 1.00, "BUY GOOGL"),
|
||||||
|
(base + timedelta(days=210), "DIVIDEND", "SPY", 0, 75.00, "USD", 0, "Cash dividend SPY"),
|
||||||
|
(base + timedelta(days=250), "FEE", None, 0, -15.00, "USD", 0, "Broker commission monthly"),
|
||||||
|
(datetime(2026, 1, 10), "DIVIDEND", "AAPL", 0, 40.00, "USD", 0, "Cash DIVIDEND AAPL"),
|
||||||
|
(datetime(2026, 2, 5), "BUY", "NVDA", 20, -2480.00, "USD", 1.00, "BUY NVDA"),
|
||||||
|
(datetime(2026, 3, 15), "DIVIDEND", "MSFT", 0, 22.00, "USD", 0, "DIV MSFT"),
|
||||||
|
(datetime(2026, 4, 1), "COUPON", "UST10Y", 0, 220.00, "USD", 0, "Bond interest payment"),
|
||||||
|
]
|
||||||
|
trades_df = pd.DataFrame(
|
||||||
|
trades,
|
||||||
|
columns=["Date", "Type", "Ticker", "Quantity", "Amount", "Currency", "Fee", "Description"],
|
||||||
|
)
|
||||||
|
|
||||||
|
cash = [
|
||||||
|
(datetime(2024, 1, 2), "DEPOSIT", None, 0, 50000.00, "USD", 0, "Wire in funding"),
|
||||||
|
(datetime(2024, 2, 10), "CARD PURCHASE", None, 0, -120.00, "USD", 0, "Starbucks Coffee"),
|
||||||
|
(datetime(2024, 3, 12), "CARD PURCHASE", None, 0, -640.00, "USD", 0, "Amazon online store"),
|
||||||
|
(datetime(2024, 5, 2), "CARD PURCHASE", None, 0, -2200.00, "USD", 0, "Booking Hotel Paris"),
|
||||||
|
(datetime(2024, 6, 18), "CARD PURCHASE", None, 0, -45.00, "USD", 0, "Uber ride"),
|
||||||
|
(datetime(2024, 8, 22), "WITHDRAWAL", None, 0, -3000.00, "USD", 0, "Cash withdrawal ATM"),
|
||||||
|
(datetime(2024, 11, 5), "CARD PURCHASE", None, 0, -300.00, "USD", 0, "Restaurant dining"),
|
||||||
|
(datetime(2026, 1, 20), "CARD PURCHASE", None, 0, -180.00, "USD", 0, "Netflix subscription bundle"),
|
||||||
|
(datetime(2026, 2, 14), "CARD PURCHASE", None, 0, -1500.00, "USD", 0, "Airline ticket"),
|
||||||
|
(datetime(2026, 3, 5), "CARD PURCHASE", None, 0, -90.00, "USD", 0, "Spotify subscription"),
|
||||||
|
(datetime(2026, 4, 8), "CARD PURCHASE", None, 0, -250.00, "USD", 0, "Apple Store purchase"),
|
||||||
|
(datetime(2026, 5, 1), "CARD PURCHASE", None, 0, -420.00, "USD", 0, "Restaurant cafe dinner"),
|
||||||
|
]
|
||||||
|
cash_df = pd.DataFrame(
|
||||||
|
cash,
|
||||||
|
columns=["Date", "Type", "Ticker", "Quantity", "Amount", "Currency", "Fee", "Description"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pd.ExcelWriter(OUT, engine="openpyxl") as writer:
|
||||||
|
trades_df.to_excel(writer, sheet_name="Trades — Interactive Brokers", index=False)
|
||||||
|
cash_df.to_excel(writer, sheet_name="Cash & Card", index=False)
|
||||||
|
|
||||||
|
print(f"Wrote {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
91
data.js
Normal file
91
data.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Atlas AI Wealth — Mock dataset
|
||||||
|
// Hand-crafted to look like a real broker portfolio (Interactive Brokers, 2024–2026)
|
||||||
|
// All numbers are made up but mathematically consistent (qty * price = mv, etc.)
|
||||||
|
|
||||||
|
window.MOCK_DATA = {
|
||||||
|
meta: {
|
||||||
|
broker: "Interactive Brokers",
|
||||||
|
base_currency: "USD",
|
||||||
|
inception: "2024-01-02",
|
||||||
|
last_updated: "2026-05-22"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Current market prices (snapshot)
|
||||||
|
prices: {
|
||||||
|
AAPL: 212.50,
|
||||||
|
MSFT: 428.00,
|
||||||
|
NVDA: 128.40,
|
||||||
|
GOOGL: 178.20,
|
||||||
|
SPY: 568.00,
|
||||||
|
UST10Y: 1000.00 // bond face value, for completeness
|
||||||
|
},
|
||||||
|
|
||||||
|
// Asset metadata for portfolio screen
|
||||||
|
asset_meta: {
|
||||||
|
AAPL: { name: "Apple Inc.", sector: "Technology" },
|
||||||
|
MSFT: { name: "Microsoft Corp.", sector: "Technology" },
|
||||||
|
NVDA: { name: "NVIDIA Corp.", sector: "Semiconductors" },
|
||||||
|
GOOGL: { name: "Alphabet Inc.", sector: "Communication" },
|
||||||
|
SPY: { name: "SPDR S&P 500 ETF", sector: "Broad Market" },
|
||||||
|
UST10Y:{ name: "US Treasury 10Y", sector: "Bonds" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 27 transactions covering Buy/Sell/Dividend/Coupon/Deposit/Withdraw/Spending/Fee
|
||||||
|
transactions: [
|
||||||
|
{ id:"t01", date:"2024-01-02", type:"DEPOSIT", asset:null, qty:0, amount: 50000.00, fee:0, category:null, description:"Wire in funding" },
|
||||||
|
{ id:"t02", date:"2024-01-05", type:"BUY", asset:"AAPL", qty:50, amount: -9200.00, fee:1, category:null, description:"Interactive Brokers — BUY AAPL" },
|
||||||
|
{ id:"t03", date:"2024-01-15", type:"BUY", asset:"MSFT", qty:20, amount: -7400.00, fee:1, category:null, description:"BUY MSFT" },
|
||||||
|
{ id:"t04", date:"2024-02-04", type:"BUY", asset:"NVDA", qty:30, amount: -2700.00, fee:1, category:null, description:"BUY NVDA" },
|
||||||
|
{ id:"t05", date:"2024-02-10", type:"SPENDING", asset:null, qty:0, amount: -120.00, fee:0, category:"restaurants", description:"Starbucks Coffee" },
|
||||||
|
{ id:"t06", date:"2024-02-19", type:"DIVIDEND", asset:"AAPL", qty:0, amount: 36.00, fee:0, category:null, description:"Cash DIVIDEND AAPL" },
|
||||||
|
{ id:"t07", date:"2024-03-12", type:"SPENDING", asset:null, qty:0, amount: -640.00, fee:0, category:"shopping", description:"Amazon online store" },
|
||||||
|
{ id:"t08", date:"2024-03-15", type:"BUY", asset:"SPY", qty:15, amount: -7200.00, fee:1, category:null, description:"BUY SPY" },
|
||||||
|
{ id:"t09", date:"2024-04-04", type:"DIVIDEND", asset:"MSFT", qty:0, amount: 18.00, fee:0, category:null, description:"Qualified DIV MSFT" },
|
||||||
|
{ id:"t10", date:"2024-05-02", type:"SPENDING", asset:null, qty:0, amount: -2200.00, fee:0, category:"travel", description:"Booking Hotel Paris" },
|
||||||
|
{ id:"t11", date:"2024-05-04", type:"SELL", asset:"AAPL", qty:10, amount: 2100.00, fee:1, category:null, description:"SELL AAPL" },
|
||||||
|
{ id:"t12", date:"2024-05-24", type:"COUPON", asset:"UST10Y",qty:0, amount: 220.00, fee:0, category:null, description:"Bond interest payment" },
|
||||||
|
{ id:"t13", date:"2024-06-18", type:"SPENDING", asset:null, qty:0, amount: -45.00, fee:0, category:"transport", description:"Uber ride" },
|
||||||
|
{ id:"t14", date:"2024-07-03", type:"BUY", asset:"GOOGL", qty:8, amount: -1320.00, fee:1, category:null, description:"BUY GOOGL" },
|
||||||
|
{ id:"t15", date:"2024-08-02", type:"DIVIDEND", asset:"SPY", qty:0, amount: 75.00, fee:0, category:null, description:"Cash dividend SPY" },
|
||||||
|
{ id:"t16", date:"2024-08-22", type:"WITHDRAWAL", asset:null, qty:0, amount: -3000.00, fee:0, category:null, description:"Cash withdrawal ATM" },
|
||||||
|
{ id:"t17", date:"2024-09-12", type:"FEE", asset:null, qty:0, amount: -15.00, fee:0, category:null, description:"Broker commission monthly" },
|
||||||
|
{ id:"t18", date:"2024-11-05", type:"SPENDING", asset:null, qty:0, amount: -300.00, fee:0, category:"restaurants", description:"Restaurant dining" },
|
||||||
|
{ id:"t19", date:"2026-01-10", type:"DIVIDEND", asset:"AAPL", qty:0, amount: 40.00, fee:0, category:null, description:"Cash DIVIDEND AAPL" },
|
||||||
|
{ id:"t20", date:"2026-01-20", type:"SPENDING", asset:null, qty:0, amount: -180.00, fee:0, category:"subscriptions", description:"Netflix subscription bundle" },
|
||||||
|
{ id:"t21", date:"2026-02-05", type:"BUY", asset:"NVDA", qty:20, amount: -2480.00, fee:1, category:null, description:"BUY NVDA" },
|
||||||
|
{ id:"t22", date:"2026-02-14", type:"SPENDING", asset:null, qty:0, amount: -1500.00, fee:0, category:"travel", description:"Airline ticket" },
|
||||||
|
{ id:"t23", date:"2026-03-05", type:"SPENDING", asset:null, qty:0, amount: -90.00, fee:0, category:"subscriptions", description:"Spotify subscription" },
|
||||||
|
{ id:"t24", date:"2026-03-15", type:"DIVIDEND", asset:"MSFT", qty:0, amount: 22.00, fee:0, category:null, description:"DIV MSFT" },
|
||||||
|
{ id:"t25", date:"2026-04-01", type:"COUPON", asset:"UST10Y",qty:0, amount: 220.00, fee:0, category:null, description:"Bond interest payment" },
|
||||||
|
{ id:"t26", date:"2026-04-08", type:"SPENDING", asset:null, qty:0, amount: -250.00, fee:0, category:"shopping", description:"Apple Store purchase" },
|
||||||
|
{ id:"t27", date:"2026-05-01", type:"SPENDING", asset:null, qty:0, amount: -420.00, fee:0, category:"restaurants", description:"Restaurant cafe dinner" }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Pre-computed analytics (so MVP doesn't need real math engine in browser)
|
||||||
|
// The numbers DO match transactions if you add them up — kept consistent.
|
||||||
|
analytics: {
|
||||||
|
cash_balance: 13664.00,
|
||||||
|
positions: [
|
||||||
|
// qty, avg_cost, cost_basis from BUY rows above; price from prices{}; mv = qty*price; pnl = mv-cost
|
||||||
|
{ asset:"MSFT", qty:20, avg_cost:370.00, cost_basis: 7400.00, market_price:428.00, market_value:8560.00, unrealized_pnl:1160.00, pnl_pct:0.1568 },
|
||||||
|
{ asset:"SPY", qty:15, avg_cost:480.00, cost_basis: 7200.00, market_price:568.00, market_value:8520.00, unrealized_pnl:1320.00, pnl_pct:0.1833 },
|
||||||
|
{ asset:"AAPL", qty:40, avg_cost:184.00, cost_basis: 7360.00, market_price:212.50, market_value:8500.00, unrealized_pnl:1140.00, pnl_pct:0.1549 },
|
||||||
|
{ asset:"NVDA", qty:50, avg_cost:103.60, cost_basis: 5180.00, market_price:128.40, market_value:6420.00, unrealized_pnl:1240.00, pnl_pct:0.2394 },
|
||||||
|
{ asset:"GOOGL", qty:8, avg_cost:165.00, cost_basis: 1320.00, market_price:178.20, market_value:1425.60, unrealized_pnl: 105.60, pnl_pct:0.0800 }
|
||||||
|
],
|
||||||
|
// totals
|
||||||
|
total_cost_basis: 28460.00,
|
||||||
|
total_market_value: 33425.60,
|
||||||
|
unrealized_pnl: 4965.60,
|
||||||
|
unrealized_pnl_pct: 0.1745,
|
||||||
|
realized_pnl: 260.00,
|
||||||
|
net_worth: 47089.60,
|
||||||
|
// performance (institutional metrics)
|
||||||
|
twr_ytd: 0.0742,
|
||||||
|
twr_inception: 0.1745,
|
||||||
|
irr_inception: 0.0850,
|
||||||
|
volatility: 0.1820,
|
||||||
|
sharpe: 0.86,
|
||||||
|
max_drawdown: -0.1240
|
||||||
|
}
|
||||||
|
};
|
||||||
445
executive-os/app.js
Normal file
445
executive-os/app.js
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
// ExecutiveOS AI — vanilla JS SPA (RU UI)
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Локализация ключей → подписи UI
|
||||||
|
const CATEGORY_LABEL = {
|
||||||
|
Approval: "Согласование",
|
||||||
|
Board: "Совет директоров",
|
||||||
|
Escalation: "Эскалация",
|
||||||
|
Financial: "Финансы",
|
||||||
|
Legal: "Правовое",
|
||||||
|
HR: "Кадры",
|
||||||
|
Informational: "Инфо",
|
||||||
|
Urgent: "Срочное"
|
||||||
|
};
|
||||||
|
const RECO_LABEL = {
|
||||||
|
Sign: "Подписать",
|
||||||
|
Approve: "Согласовать",
|
||||||
|
Vote: "Голосовать",
|
||||||
|
Review: "Проверить",
|
||||||
|
Reject: "Отклонить",
|
||||||
|
Escalate: "Эскалировать",
|
||||||
|
Delegate: "Делегировать",
|
||||||
|
Ignore: "Игнорировать"
|
||||||
|
};
|
||||||
|
const PRI_LABEL = {
|
||||||
|
P1: "P1 · Критический",
|
||||||
|
P2: "P2 · Высокий",
|
||||||
|
P3: "P3 · Обычный"
|
||||||
|
};
|
||||||
|
const RESOLVED_LABEL = {
|
||||||
|
"Signed": "Подписано",
|
||||||
|
"Approved": "Согласовано",
|
||||||
|
"Rejected": "Отклонено",
|
||||||
|
"Delegated": "Делегировано",
|
||||||
|
"Escalated": "Эскалировано",
|
||||||
|
"Ignored": "Проигнорировано",
|
||||||
|
"Reviewed": "Просмотрено",
|
||||||
|
"Voted FOR": "Голос ЗА",
|
||||||
|
"Voted AGAINST": "Голос ПРОТИВ",
|
||||||
|
"Done": "Готово"
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE = {
|
||||||
|
route: "briefing",
|
||||||
|
resolved: {} // локальное состояние, без backend
|
||||||
|
};
|
||||||
|
|
||||||
|
const D = window.MOCK_DATA;
|
||||||
|
|
||||||
|
// ---------- Утилиты ----------
|
||||||
|
function $(sel, root = document) { return root.querySelector(sel); }
|
||||||
|
function $$(sel, root = document) { return Array.from(root.querySelectorAll(sel)); }
|
||||||
|
|
||||||
|
function fmtAge(days) {
|
||||||
|
if (days <= 0) return "сегодня";
|
||||||
|
if (days === 1) return "1 день назад";
|
||||||
|
if (days >= 2 && days <= 4) return `${days} дня назад`;
|
||||||
|
return `${days} дн. назад`;
|
||||||
|
}
|
||||||
|
function slaState(email) {
|
||||||
|
if (!email.sla_hours) return { label: "—", state: "ok" };
|
||||||
|
const hoursElapsed = email.age_days * 24;
|
||||||
|
const remaining = email.sla_hours - hoursElapsed;
|
||||||
|
if (remaining < 0) return { label: `просрочен на ${Math.abs(remaining).toFixed(0)} ч`, state: "breach" };
|
||||||
|
if (remaining < 12) return { label: `< ${remaining.toFixed(0)} ч`, state: "near" };
|
||||||
|
return { label: `${remaining.toFixed(0)} ч`, state: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(name) {
|
||||||
|
const tpl = document.getElementById("tpl-" + name);
|
||||||
|
const view = document.getElementById("view");
|
||||||
|
view.innerHTML = "";
|
||||||
|
if (tpl) view.appendChild(tpl.content.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(route) {
|
||||||
|
STATE.route = route;
|
||||||
|
$$("#nav button").forEach(b => b.classList.toggle("active", b.dataset.route === route));
|
||||||
|
location.hash = route;
|
||||||
|
if (route === "briefing") renderBriefing();
|
||||||
|
else if (route === "inbox") renderInbox();
|
||||||
|
else if (route === "approvals") renderApprovals();
|
||||||
|
else if (route === "escalations") renderEscalations();
|
||||||
|
else if (route === "search") renderSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Брифинг ----------
|
||||||
|
function renderBriefing() {
|
||||||
|
mount("briefing");
|
||||||
|
const dateEl = $("#brief-date");
|
||||||
|
dateEl.textContent =
|
||||||
|
`${D.meta.today} · последняя синхронизация ${new Date(D.meta.last_sync).toLocaleString("ru-RU")} · источник: ${D.meta.source}`;
|
||||||
|
|
||||||
|
const emails = D.emails;
|
||||||
|
const critical = emails.filter(e => e.priority === "P1" && e.unread).length;
|
||||||
|
const pending = emails.filter(e => e.action_required && !STATE.resolved[e.id]).length;
|
||||||
|
const boardItems = emails.filter(e => e.category === "Board").length;
|
||||||
|
const slaBreaches = emails.filter(e => slaState(e).state === "breach").length;
|
||||||
|
|
||||||
|
setKpi("critical", critical);
|
||||||
|
setKpi("pending", pending);
|
||||||
|
setKpi("board", boardItems);
|
||||||
|
setKpi("sla", slaBreaches);
|
||||||
|
|
||||||
|
$("#brief-summary").textContent = D.briefing_text;
|
||||||
|
|
||||||
|
const top3 = emails
|
||||||
|
.filter(e => e.action_required && !STATE.resolved[e.id])
|
||||||
|
.sort((a, b) => b.ai_score - a.ai_score)
|
||||||
|
.slice(0, 3);
|
||||||
|
const ol = $("#brief-top3");
|
||||||
|
top3.forEach(e => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.innerHTML = `
|
||||||
|
<div><strong>${escapeHtml(e.subject)}</strong></div>
|
||||||
|
<span class="sub">
|
||||||
|
<span class="pri-${e.priority}" style="font-weight:600">${PRI_LABEL[e.priority]}</span> ·
|
||||||
|
${escapeHtml(e.from.name)} (${escapeHtml(e.from.title)}) ·
|
||||||
|
AI-балл ${e.ai_score} · рекомендация: <strong>${RECO_LABEL[e.recommendation]}</strong>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
ol.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn-refresh").onclick = () => {
|
||||||
|
$("#btn-refresh").textContent = "Синхронизация…";
|
||||||
|
setTimeout(() => navigate("briefing"), 400);
|
||||||
|
};
|
||||||
|
$("#btn-mark-read").onclick = () => {
|
||||||
|
D.emails.forEach(e => e.unread = false);
|
||||||
|
navigate("briefing");
|
||||||
|
};
|
||||||
|
|
||||||
|
initCopilot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKpi(key, value) {
|
||||||
|
const el = $(`[data-k="${key}"]`);
|
||||||
|
if (el) el.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- AI-копилот ----------
|
||||||
|
const CP_SUGGESTIONS = [
|
||||||
|
"Что требует моей подписи?",
|
||||||
|
"Что просрочено?",
|
||||||
|
"Покажи критические инциденты",
|
||||||
|
"Что готовить к заседанию Совета?",
|
||||||
|
"Что можно делегировать?"
|
||||||
|
];
|
||||||
|
|
||||||
|
function initCopilot() {
|
||||||
|
const sug = $("#cp-suggest");
|
||||||
|
CP_SUGGESTIONS.forEach(q => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "chip"; b.textContent = q;
|
||||||
|
b.onclick = () => askCopilot(q);
|
||||||
|
sug.appendChild(b);
|
||||||
|
});
|
||||||
|
$("#cp-ask").onclick = () => {
|
||||||
|
const v = $("#cp-input").value.trim();
|
||||||
|
if (v) askCopilot(v);
|
||||||
|
};
|
||||||
|
$("#cp-input").addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Enter") $("#cp-ask").click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function askCopilot(question) {
|
||||||
|
const q = question.toLowerCase();
|
||||||
|
const out = $("#cp-answer");
|
||||||
|
const items = D.emails;
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
const matchSign = /(подпис|sign|подпишу|sign-off)/i.test(q);
|
||||||
|
const matchOverdue = /(просроч|overdue|опазд|дедлайн)/i.test(q);
|
||||||
|
const matchUrgent = /(критич|срочн|urgent|p1|инцидент|p-?1)/i.test(q);
|
||||||
|
const matchBoard = /(совет|board|заседан|комитет)/i.test(q);
|
||||||
|
const matchDelegate = /(делегир|delegate|раздать|передать)/i.test(q);
|
||||||
|
const matchPriority = /(приоритет|priorit|важн|top|топ)/i.test(q);
|
||||||
|
|
||||||
|
if (matchSign) {
|
||||||
|
const sign = items.filter(e => e.recommendation === "Sign" && !STATE.resolved[e.id]);
|
||||||
|
parts.push(formatList("✍️ Требуют подписи:", sign));
|
||||||
|
}
|
||||||
|
if (matchOverdue) {
|
||||||
|
const over = items.filter(e => slaState(e).state === "breach");
|
||||||
|
parts.push(formatList("⏰ Просрочены по SLA:", over));
|
||||||
|
}
|
||||||
|
if (matchUrgent) {
|
||||||
|
const urg = items.filter(e => e.priority === "P1");
|
||||||
|
parts.push(formatList("🚨 Критические (P1):", urg));
|
||||||
|
}
|
||||||
|
if (matchBoard) {
|
||||||
|
const brd = items.filter(e => e.category === "Board");
|
||||||
|
parts.push(formatList("🏛 К заседанию Совета:", brd));
|
||||||
|
}
|
||||||
|
if (matchDelegate) {
|
||||||
|
const del = items.filter(e => e.recommendation === "Delegate");
|
||||||
|
parts.push(formatList("↪ Можно делегировать:", del));
|
||||||
|
}
|
||||||
|
if (matchPriority || parts.length === 0) {
|
||||||
|
const top = items
|
||||||
|
.filter(e => e.action_required && !STATE.resolved[e.id])
|
||||||
|
.sort((a, b) => b.ai_score - a.ai_score)
|
||||||
|
.slice(0, 5);
|
||||||
|
parts.push(formatList("🎯 Топ-5 по AI-баллу:", top));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.textContent = parts.join("\n\n");
|
||||||
|
out.classList.add("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatList(header, items) {
|
||||||
|
if (items.length === 0) return header + "\n (пусто)";
|
||||||
|
const lines = items.map(e => {
|
||||||
|
const sla = slaState(e);
|
||||||
|
return ` • [${e.priority}] ${e.subject}\n от ${e.from.name} (${e.from.title}) · ${fmtAge(e.age_days)} · SLA ${sla.label} · → ${RECO_LABEL[e.recommendation]}`;
|
||||||
|
});
|
||||||
|
return header + "\n" + lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Входящие ----------
|
||||||
|
function renderInbox() {
|
||||||
|
mount("inbox");
|
||||||
|
const catSel = $("#inbox-cat");
|
||||||
|
D.categories.forEach(c => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = c; o.textContent = CATEGORY_LABEL[c] || c;
|
||||||
|
catSel.appendChild(o);
|
||||||
|
});
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const q = $("#inbox-search").value.toLowerCase().trim();
|
||||||
|
const cat = catSel.value;
|
||||||
|
const pri = $("#inbox-pri").value;
|
||||||
|
const unreadOnly = $("#inbox-unread").checked;
|
||||||
|
const list = $("#inbox-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
const filtered = D.emails
|
||||||
|
.filter(e => !q ||
|
||||||
|
e.subject.toLowerCase().includes(q) ||
|
||||||
|
e.from.name.toLowerCase().includes(q) ||
|
||||||
|
(e.preview || "").toLowerCase().includes(q))
|
||||||
|
.filter(e => !cat || e.category === cat)
|
||||||
|
.filter(e => !pri || e.priority === pri)
|
||||||
|
.filter(e => !unreadOnly || e.unread)
|
||||||
|
.sort((a, b) => b.ai_score - a.ai_score);
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
list.innerHTML = `<div class="card muted small" style="text-align:center;padding:24px">Ничего не найдено.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filtered.forEach(e => list.appendChild(renderEmailCard(e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
$("#inbox-search").addEventListener("input", draw);
|
||||||
|
catSel.addEventListener("change", draw);
|
||||||
|
$("#inbox-pri").addEventListener("change", draw);
|
||||||
|
$("#inbox-unread").addEventListener("change", draw);
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmailCard(e) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = `email${e.unread ? " unread" : ""}${e.priority === "P1" ? " urgent" : ""}`;
|
||||||
|
const cat = e.category.toLowerCase();
|
||||||
|
const sla = slaState(e);
|
||||||
|
const slaColor = sla.state === "breach" ? "danger" : sla.state === "near" ? "warn" : "muted";
|
||||||
|
div.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<span class="pri-badge pri-${e.priority}">${e.priority}</span>
|
||||||
|
<div class="score-bar" title="AI-балл ${e.ai_score}"><div class="score-fill" style="width:${e.ai_score}%"></div></div>
|
||||||
|
<div class="muted small mono r" style="margin-top:4px">${e.ai_score}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="subject">${escapeHtml(e.subject)}</div>
|
||||||
|
<div class="from">${escapeHtml(e.from.name)} · ${escapeHtml(e.from.title)} · ${escapeHtml(e.from.email)}</div>
|
||||||
|
<div class="preview">${escapeHtml(e.preview)}</div>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag cat-${cat}">${CATEGORY_LABEL[e.category] || e.category}</span>
|
||||||
|
${e.attachments ? `<span class="tag">📎 ${e.attachments}</span>` : ""}
|
||||||
|
${e.unread ? `<span class="tag" style="color:var(--accent);border-color:var(--accent)">новое</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="age">${fmtAge(e.age_days)}</div>
|
||||||
|
${e.sla_hours ? `<div class="age ${slaColor}">SLA ${sla.label}</div>` : ""}
|
||||||
|
<div class="reco reco-${e.recommendation.toLowerCase()}">→ ${RECO_LABEL[e.recommendation]}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
div.onclick = () => alert(
|
||||||
|
`AI-обоснование:\n\n${e.ai_rationale}\n\n— Сводка —\n${e.summary}`
|
||||||
|
);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Согласования ----------
|
||||||
|
function renderApprovals() {
|
||||||
|
mount("approvals");
|
||||||
|
const list = $("#appr-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
const items = D.emails
|
||||||
|
.filter(e => e.action_required &&
|
||||||
|
["Sign", "Approve", "Vote", "Review", "Reject", "Escalate", "Delegate"].includes(e.recommendation))
|
||||||
|
.sort((a, b) => b.ai_score - a.ai_score);
|
||||||
|
|
||||||
|
items.forEach(e => {
|
||||||
|
const resolved = STATE.resolved[e.id];
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "approval" + (resolved ? " resolved" : "");
|
||||||
|
const sla = slaState(e);
|
||||||
|
const slaClass = sla.state === "breach" ? "danger" : sla.state === "near" ? "warn" : "muted";
|
||||||
|
card.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="head">
|
||||||
|
<span class="pri-badge pri-${e.priority}">${e.priority}</span>
|
||||||
|
<span class="tag cat-${e.category.toLowerCase()}">${CATEGORY_LABEL[e.category] || e.category}</span>
|
||||||
|
${resolved ? `<span class="resolved-tag">✓ ${RESOLVED_LABEL[resolved] || resolved}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="subject" style="margin-top:10px">${escapeHtml(e.subject)}</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span>от <strong>${escapeHtml(e.from.name)}</strong> (${escapeHtml(e.from.title)})</span>
|
||||||
|
<span>${fmtAge(e.age_days)}</span>
|
||||||
|
${e.sla_hours ? `<span class="${slaClass}">SLA ${sla.label}</span>` : ""}
|
||||||
|
${e.attachments ? `<span>📎 вложений: ${e.attachments}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="summary">${escapeHtml(e.summary)}</div>
|
||||||
|
<div class="ai-box">
|
||||||
|
<strong>AI · балл ${e.ai_score}/100 · → ${RECO_LABEL[e.recommendation]}</strong><br/>
|
||||||
|
<span class="muted small">${escapeHtml(e.ai_rationale)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
${approvalButtons(e)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.querySelectorAll("button[data-act]").forEach(b => {
|
||||||
|
b.onclick = () => {
|
||||||
|
STATE.resolved[e.id] = b.dataset.act;
|
||||||
|
renderApprovals();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = `<div class="card muted small" style="text-align:center;padding:24px">Открытых решений нет.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalButtons(e) {
|
||||||
|
const r = e.recommendation;
|
||||||
|
if (r === "Sign") return `<button class="btn-sign" data-act="Signed">✍️ Подписать</button><button data-act="Delegated" class="btn-delegate">Делегировать</button>`;
|
||||||
|
if (r === "Approve") return `<button class="btn-approve" data-act="Approved">✓ Согласовать</button><button data-act="Rejected" class="btn-reject">Отклонить</button><button data-act="Delegated" class="btn-delegate">Делегировать</button>`;
|
||||||
|
if (r === "Vote") return `<button class="btn-approve" data-act="Voted FOR">Голос ЗА</button><button data-act="Voted AGAINST" class="btn-reject">Голос ПРОТИВ</button>`;
|
||||||
|
if (r === "Review") return `<button class="btn-approve" data-act="Reviewed">✓ Просмотрено</button><button data-act="Delegated" class="btn-delegate">Делегировать</button>`;
|
||||||
|
if (r === "Escalate") return `<button class="btn-reject" data-act="Escalated">↑ Эскалировать</button><button data-act="Ignored" class="btn-delegate">Игнорировать</button>`;
|
||||||
|
if (r === "Delegate") return `<button data-act="Delegated" class="btn-delegate">↪ Делегировать</button><button class="btn-approve" data-act="Approved">Решить самому</button>`;
|
||||||
|
return `<button data-act="Done" class="btn-approve">Готово</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Эскалации ----------
|
||||||
|
function renderEscalations() {
|
||||||
|
mount("escalations");
|
||||||
|
const rows = D.emails
|
||||||
|
.filter(e => e.category === "Escalation" || e.priority === "P1")
|
||||||
|
.sort((a, b) => b.age_days - a.age_days);
|
||||||
|
const tbody = $("#esc-rows");
|
||||||
|
rows.forEach(e => {
|
||||||
|
const sla = slaState(e);
|
||||||
|
const slaCls = sla.state === "breach" ? "danger" : sla.state === "near" ? "warn" : "muted";
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const status = STATE.resolved[e.id]
|
||||||
|
? `<span class="accent">${RESOLVED_LABEL[STATE.resolved[e.id]] || STATE.resolved[e.id]}</span>`
|
||||||
|
: `<span class="${e.priority === 'P1' ? 'danger' : 'warn'}">${RECO_LABEL[e.recommendation]}</span>`;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="mono">${fmtAge(e.age_days)}</td>
|
||||||
|
<td>${escapeHtml(e.subject)}</td>
|
||||||
|
<td class="muted small">${escapeHtml(e.from.name)}</td>
|
||||||
|
<td>${escapeHtml(e.from.title)}</td>
|
||||||
|
<td class="r ${slaCls}">${sla.label}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
if (rows.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" class="muted small" style="text-align:center;padding:24px">Открытых эскалаций нет.</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Поиск ----------
|
||||||
|
function renderSearch() {
|
||||||
|
mount("search");
|
||||||
|
const sug = $("#srch-suggest");
|
||||||
|
["бюджет", "EU AI Act", "Совет", "Председатель", "аудит", "Сокол", "SecOps", "ESG"].forEach(s => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "chip"; b.textContent = s;
|
||||||
|
b.onclick = () => { $("#srch-input").value = s; doSearch(); };
|
||||||
|
sug.appendChild(b);
|
||||||
|
});
|
||||||
|
$("#srch-go").onclick = doSearch;
|
||||||
|
$("#srch-input").addEventListener("keydown", e => { if (e.key === "Enter") doSearch(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearch() {
|
||||||
|
const q = $("#srch-input").value.toLowerCase().trim();
|
||||||
|
const out = $("#srch-results");
|
||||||
|
out.innerHTML = "";
|
||||||
|
if (!q) return;
|
||||||
|
const hay = (e) =>
|
||||||
|
(e.subject + " " + e.preview + " " + e.summary + " " +
|
||||||
|
e.from.name + " " + e.from.title + " " +
|
||||||
|
(CATEGORY_LABEL[e.category] || "") + " " + e.category).toLowerCase();
|
||||||
|
const results = D.emails.filter(e => hay(e).includes(q));
|
||||||
|
if (results.length === 0) {
|
||||||
|
out.innerHTML = `<div class="card muted small" style="text-align:center;padding:24px">Совпадений нет.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.forEach(e => out.appendChild(renderEmailCard(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Router ----------
|
||||||
|
document.getElementById("nav").addEventListener("click", (e) => {
|
||||||
|
const r = e.target.dataset.route;
|
||||||
|
if (r) navigate(r);
|
||||||
|
});
|
||||||
|
window.addEventListener("hashchange", () => {
|
||||||
|
const r = location.hash.replace("#", "") || "briefing";
|
||||||
|
if (r !== STATE.route) navigate(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = location.hash.replace("#", "") || "briefing";
|
||||||
|
navigate(start);
|
||||||
|
})();
|
||||||
351
executive-os/data.js
Normal file
351
executive-os/data.js
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
// ExecutiveOS AI — мок-данные (демо интеграции HCL Notes / Lotus Notes)
|
||||||
|
// Все имена/отправители/суммы вымышлены. Компания: АО «АКМЕ Холдинг».
|
||||||
|
// "сегодня" = 2026-05-22 для расчета возраста писем и SLA в app.js.
|
||||||
|
|
||||||
|
window.MOCK_DATA = {
|
||||||
|
meta: {
|
||||||
|
today: "2026-05-22",
|
||||||
|
user: "ceo@acme.corp",
|
||||||
|
source: "HCL Notes / Lotus Notes (Domino bridge)",
|
||||||
|
last_sync: "2026-05-22T08:14:00Z"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Канонические ключи (для логики) → русские подписи (для UI) — в app.js
|
||||||
|
categories: ["Approval", "Board", "Escalation", "Financial", "Legal", "HR", "Informational", "Urgent"],
|
||||||
|
priorities: ["P1", "P2", "P3"],
|
||||||
|
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
id: "e01",
|
||||||
|
received_at: "2026-05-22T07:42:00Z",
|
||||||
|
from: { name: "Анна Петрова", email: "chairman@acme.corp", title: "Председатель совета директоров" },
|
||||||
|
subject: "Утверждение CAPEX Q2 — пересмотренный план $42 млн",
|
||||||
|
preview: "Прошу рассмотреть и подписать пересмотренный план CAPEX до заседания Совета в пятницу. Ключевые изменения: +$8M на AI-инфраструктуру, −$3M из устаревшего ЦОД.",
|
||||||
|
summary: "Председатель Совета просит подпись CEO на пересмотренном CAPEX Q2 ($42 млн). Главный сдвиг: +$8M в AI-инфра, −$3M из легаси-ЦОД. Привязано к голосованию Совета в пятницу.",
|
||||||
|
category: "Approval",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 96,
|
||||||
|
recommendation: "Sign",
|
||||||
|
ai_rationale: "Отправитель — Председатель Совета (высшая инстанция). Тема пересекается с голосованием через 3 дня. Риск: срыв дедлайна срывает закрытие Q2-бюджета. Исторически подобные пересмотры CAPEX одобряются в 9 случаях из 10.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 0,
|
||||||
|
attachments: 2,
|
||||||
|
sla_hours: 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e02",
|
||||||
|
received_at: "2026-05-21T18:11:00Z",
|
||||||
|
from: { name: "Маркус Лян", email: "cfo@acme.corp", title: "Финансовый директор (CFO)" },
|
||||||
|
subject: "Резерв непредвиденных расходов FY26 — на подпись",
|
||||||
|
preview: "Финансы закрыли резерв на уровне 3,2% от opex. Требуется электронная подпись на приложенном меморандуме до конца квартала.",
|
||||||
|
summary: "CFO подтверждает резерв FY26 на уровне 3,2% от opex ($14,1 млн). Стандартная подпись меморандума, изменений политики vs прошлого года нет.",
|
||||||
|
category: "Financial",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 82,
|
||||||
|
recommendation: "Sign",
|
||||||
|
ai_rationale: "Рутинный финансовый контроль. Соответствует прошлогоднему паттерну. Низкий операционный риск. Рекомендация: подписать.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 1,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e03",
|
||||||
|
received_at: "2026-05-21T14:30:00Z",
|
||||||
|
from: { name: "Юридический департамент", email: "legal@acme.corp", title: "Главный юрист" },
|
||||||
|
subject: "Продление контракта с TechCo MSA — $1,2 млн/год",
|
||||||
|
preview: "Продление MSA с TechCo. Лимит ответственности снижен с 12 до 6 месяцев платежей. Просьба рассмотреть или делегировать.",
|
||||||
|
summary: "Legal эскалирует продление MSA с TechCo. Особенность: лимит ответственности уменьшен в 2 раза. Стоимость контракта $1,2 млн/год.",
|
||||||
|
category: "Legal",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 74,
|
||||||
|
recommendation: "Review",
|
||||||
|
ai_rationale: "Условия ответственности существенно слабее предыдущего MSA. Ниже обязательного порога ревью CEO ($5 млн), но нестандартное изменение требует внимания.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 1,
|
||||||
|
attachments: 3,
|
||||||
|
sla_hours: 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e04",
|
||||||
|
received_at: "2026-05-20T22:05:00Z",
|
||||||
|
from: { name: "SecOps Alert", email: "secops@acme.corp", title: "Служба безопасности" },
|
||||||
|
subject: "ИНЦИДЕНТ P1 — Подозрительный вход админа с внешнего IP",
|
||||||
|
preview: "Привилегированный аккаунт 'svc-domino-admin' аутентифицировался с не-VPN IP 185.x.x.x в 22:04 UTC. Учётные данные автоматически ротированы. Ожидаем решение об эскалации.",
|
||||||
|
summary: "SecOps зафиксировал подозрительный привилегированный вход. Учётные данные авторотированы. Утечки данных не зафиксировано. SecOps просит CEO эскалировать к CISO + уведомить аудит-комитет Совета.",
|
||||||
|
category: "Escalation",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 99,
|
||||||
|
recommendation: "Escalate",
|
||||||
|
ai_rationale: "Паттерн компрометации привилегированной учётки. Вероятно сработал порог обязательного раскрытия. Рекомендация: немедленная эскалация к CISO и предварительное уведомление аудит-комитета.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 2,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e05",
|
||||||
|
received_at: "2026-05-20T11:30:00Z",
|
||||||
|
from: { name: "Сара Ким", email: "chro@acme.corp", title: "Директор по персоналу (CHRO)" },
|
||||||
|
subject: "Промоушены до уровня директор — 3 кандидата",
|
||||||
|
preview: "Три промоушена до директора в Q2. Все прошли Совет по талантам. Требуется финальное одобрение CEO.",
|
||||||
|
summary: "CHRO просит подпись по 3 промоушенам директоров. Все предварительно одобрены Советом по талантам. Годовое влияние на ФОТ +$420K.",
|
||||||
|
category: "HR",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 71,
|
||||||
|
recommendation: "Approve",
|
||||||
|
ai_rationale: "Стандартный пакет промоушенов. Совет по талантам уже одобрил. Стоимость в пределах Q2-плана. Исторический процент одобрения CEO предодобренных промоушенов: 94%.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 2,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e06",
|
||||||
|
received_at: "2026-05-19T16:45:00Z",
|
||||||
|
from: { name: "Секретариат Совета", email: "board-sec@acme.corp", title: "Секретариат Совета директоров" },
|
||||||
|
subject: "Заседание Совета 24 мая — материалы pre-read (4 документа)",
|
||||||
|
preview: "Pre-read: обновление стратегии Q2, M&A-pipeline, риск-дашборд, ESG-отчет. Просьба ознакомиться до сессии 09:00 в пятницу.",
|
||||||
|
summary: "Пакет материалов к заседанию Совета 24 мая. 4 документа: стратегия Q2, M&A-pipeline, риск-дашборд, ESG. Всего ~62 страницы. AI-сводка приложена.",
|
||||||
|
category: "Board",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 88,
|
||||||
|
recommendation: "Review",
|
||||||
|
ai_rationale: "Стандартный pre-read Совета. Сгенерирована автоматическая сводка. M&A-pipeline содержит 1 материальный пункт, требующий знакомства CEO до голосования.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 3,
|
||||||
|
attachments: 4,
|
||||||
|
sla_hours: 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e07",
|
||||||
|
received_at: "2026-05-19T09:00:00Z",
|
||||||
|
from: { name: "Investor Relations", email: "ir@acme.corp", title: "Глава IR" },
|
||||||
|
subject: "Сценарий звонка с инвесторами — финальный драфт",
|
||||||
|
preview: "Финальный сценарий звонка 28 мая. Тезисы согласованы с CFO. Просьба проверить вступительное слово.",
|
||||||
|
summary: "Сценарий звонка с инвесторами v3. Вступление акцентирует ROI AI-инфраструктуры. Согласовано с CFO. Два варианта Q&A ожидают предпочтения CEO.",
|
||||||
|
category: "Financial",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 78,
|
||||||
|
recommendation: "Review",
|
||||||
|
ai_rationale: "Публичный материал. Две формулировки про AI ROI — рекомендую консервативный вариант, проверенный юристами, учитывая предыдущий запрос SEC.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 3,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 144
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e08",
|
||||||
|
received_at: "2026-05-18T20:11:00Z",
|
||||||
|
from: { name: "Внутренний аудит", email: "audit@acme.corp", title: "Глава внутреннего аудита" },
|
||||||
|
subject: "Аудит — нарушение разделения полномочий (Закупки)",
|
||||||
|
preview: "Аудит выявил 2 случая, когда один и тот же пользователь утверждал PO и проводил оплату. План устранения приложен.",
|
||||||
|
summary: "Внутренний аудит поднимает разрыв SoD в Закупках (2 случая). План устранения готов. Рекомендуется уведомление Аудит-комитета.",
|
||||||
|
category: "Escalation",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 84,
|
||||||
|
recommendation: "Escalate",
|
||||||
|
ai_rationale: "Находка по SoD имеет комплаенс-последствия. Рекомендую эскалировать в Аудит-комитет на следующем заседании. План устранения уже подготовлен.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 4,
|
||||||
|
attachments: 2,
|
||||||
|
sla_hours: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e09",
|
||||||
|
received_at: "2026-05-18T13:22:00Z",
|
||||||
|
from: { name: "Команда M&A", email: "ma@acme.corp", title: "Глава Corporate Development" },
|
||||||
|
subject: "Проект «Сокол» — term sheet, требуется голосование",
|
||||||
|
preview: "Term sheet по проекту «Сокол» (цель — NorthernCloud). Голосование Совета 24 мая. Просьба предварительного одобрения.",
|
||||||
|
summary: "Команда M&A подаёт term sheet по NorthernCloud ($380 млн). Стратегическое соответствие: расширение AI-инфраструктуры. Due diligence выявил 1 IP-замечание (mitigated).",
|
||||||
|
category: "Board",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 92,
|
||||||
|
recommendation: "Vote",
|
||||||
|
ai_rationale: "Материальная M&A-сделка. Требуется голосование Совета. Предварительное одобрение CEO сигнализирует о поддержке. IP-вопрос отмечен в DD; юридическое заключение приложено.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 4,
|
||||||
|
attachments: 5,
|
||||||
|
sla_hours: 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e10",
|
||||||
|
received_at: "2026-05-17T08:00:00Z",
|
||||||
|
from: { name: "Операционный директор", email: "coo@acme.corp", title: "COO" },
|
||||||
|
subject: "Сбой в EU-регионе — драфт post-mortem",
|
||||||
|
preview: "Post-mortem сбоя в EU-регионе 14 мая. RCA: неверно настроенный failover. План действий: 4 направления. FYI.",
|
||||||
|
summary: "COO делится post-mortem сбоя в EU 14 мая. Простой 47 мин, затронуло 12K пользователей. Причина: misconfig failover. 4 направления устранения.",
|
||||||
|
category: "Informational",
|
||||||
|
priority: "P3",
|
||||||
|
ai_score: 38,
|
||||||
|
recommendation: "Ignore",
|
||||||
|
ai_rationale: "Информационное. Owner — COO. Действия CEO не требуются, если только Risk-комитет Совета не эскалирует. Помечено FYI.",
|
||||||
|
action_required: false,
|
||||||
|
unread: false,
|
||||||
|
age_days: 5,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e11",
|
||||||
|
received_at: "2026-05-17T07:14:00Z",
|
||||||
|
from: { name: "Анна Петрова", email: "chairman@acme.corp", title: "Председатель совета директоров" },
|
||||||
|
subject: "Re: Стратегическая сессия — даты в июне",
|
||||||
|
preview: "Подтверждаю 18–19 июня для стратегической сессии. Прошу ответить со списком участников исполкома.",
|
||||||
|
summary: "Председатель подтверждает даты стратегической сессии (18–19 июня). Ждёт подтверждения CEO + список участников.",
|
||||||
|
category: "Approval",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 75,
|
||||||
|
recommendation: "Approve",
|
||||||
|
ai_rationale: "Подтверждение календаря от Председателя. VIP-отправитель. Быстрый ответ усиливает сигнал доверия. Черновик ответа готов.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 5,
|
||||||
|
attachments: 0,
|
||||||
|
sla_hours: 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e12",
|
||||||
|
received_at: "2026-05-16T19:33:00Z",
|
||||||
|
from: { name: "Маркетинг", email: "cmo@acme.corp", title: "CMO" },
|
||||||
|
subject: "Бренд-кампания — финальное согласование креатива",
|
||||||
|
preview: "Финальный креатив для бренд-кампании Q3. Просьба проверить приложенное видео + 4 print-варианта.",
|
||||||
|
summary: "CMO просит подпись по креативу бренд-кампании Q3. Соответствует бренд-гайдлайнам. Согласовано с командой Comms.",
|
||||||
|
category: "Approval",
|
||||||
|
priority: "P3",
|
||||||
|
ai_score: 58,
|
||||||
|
recommendation: "Delegate",
|
||||||
|
ai_rationale: "Операционное маркетинговое решение. Рекомендую делегировать CMO + VP Comms, если у CEO нет стратегических предпочтений.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 6,
|
||||||
|
attachments: 5,
|
||||||
|
sla_hours: 168
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e13",
|
||||||
|
received_at: "2026-05-15T12:00:00Z",
|
||||||
|
from: { name: "ESG-комитет", email: "esg@acme.corp", title: "Глава ESG" },
|
||||||
|
subject: "Годовой отчет по устойчивому развитию — на подпись",
|
||||||
|
preview: "Годовой ESG-отчет приложен. Требуется подпись руководства перед публичной подачей.",
|
||||||
|
summary: "ESG-отчет готов к публичной подаче. Проверен Legal + ESG-комитетом. Требуется подпись CEO на сопроводительном письме.",
|
||||||
|
category: "Approval",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 76,
|
||||||
|
recommendation: "Sign",
|
||||||
|
ai_rationale: "Годовая публикация. Стандартный процесс. Все upstream-проверки завершены. Дедлайн подачи — 31 мая.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 7,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e14",
|
||||||
|
received_at: "2026-05-14T10:15:00Z",
|
||||||
|
from: { name: "HR Operations", email: "hr-ops@acme.corp", title: "Директор HR" },
|
||||||
|
subject: "Whistleblower-обращение — чувствительное (HR-2026-0042)",
|
||||||
|
preview: "Конфиденциальное обращение по анонимному каналу. Запущено внутреннее расследование. Брифинг для CEO eyes-only.",
|
||||||
|
summary: "Конфиденциальный whistleblower-кейс (HR-2026-0042). Расследуют HR + Legal. На этом этапе — только процедурное уведомление.",
|
||||||
|
category: "Escalation",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 95,
|
||||||
|
recommendation: "Escalate",
|
||||||
|
ai_rationale: "Чувствительный HR-вопрос. Рекомендую конфиденциальный брифинг с Главным юристом и Главой HR. Возможно уведомление Аудит-комитета в зависимости от масштаба.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 8,
|
||||||
|
attachments: 0,
|
||||||
|
sla_hours: 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e15",
|
||||||
|
received_at: "2026-05-14T09:00:00Z",
|
||||||
|
from: { name: "Благотворительный фонд", email: "donations@external.org", title: "Директор" },
|
||||||
|
subject: "Партнёрство — спонсорство ежегодного гала",
|
||||||
|
preview: "Предложение спонсорства гала-вечера. Уровни от $25K до $200K. Возможность для бренда.",
|
||||||
|
summary: "Внешний питч благотворительного партнёрства. Уровни $25K–$200K. Бренд-соответствие умеренное.",
|
||||||
|
category: "Informational",
|
||||||
|
priority: "P3",
|
||||||
|
ai_score: 22,
|
||||||
|
recommendation: "Delegate",
|
||||||
|
ai_rationale: "Внешнее обращение. Ниже порога CEO. Передать в Comms или Фонд.",
|
||||||
|
action_required: false,
|
||||||
|
unread: false,
|
||||||
|
age_days: 8,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e16",
|
||||||
|
received_at: "2026-05-12T15:00:00Z",
|
||||||
|
from: { name: "Риск-комитет", email: "risk@acme.corp", title: "Директор по рискам" },
|
||||||
|
subject: "Обновление реестра рисков за квартал",
|
||||||
|
preview: "Реестр рисков Q2 обновлён. Два новых «красных» пункта: цепочка поставок (полупроводники) и регуляторика (EU AI Act).",
|
||||||
|
summary: "Реестр рисков Q2: два новых «красных» пункта — риск полупроводниковой цепочки, комплаенс EU AI Act. Назначены ответственные за митигацию.",
|
||||||
|
category: "Board",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 83,
|
||||||
|
recommendation: "Review",
|
||||||
|
ai_rationale: "Существенные изменения в реестре рисков. Стоит ознакомиться CEO до риск-сессии Совета на следующей неделе.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 10,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e17",
|
||||||
|
received_at: "2026-05-10T08:45:00Z",
|
||||||
|
from: { name: "Speaker Bureau", email: "bookings@speakerbureau.com", title: "Букинг-агент" },
|
||||||
|
subject: "Приглашение с keynote — TechSummit Берлин, окт 2026",
|
||||||
|
preview: "Приглашение с keynote. Гонорар предложен. Тема: «AI-native enterprises». Аудитория: ~2000 руководителей.",
|
||||||
|
summary: "Внешнее приглашение к выступлению. Берлин, октябрь 2026. Соответствует thought leadership.",
|
||||||
|
category: "Informational",
|
||||||
|
priority: "P3",
|
||||||
|
ai_score: 45,
|
||||||
|
recommendation: "Delegate",
|
||||||
|
ai_rationale: "Внешнее приглашение. Передать в Comms для оценки. Срочного решения не требуется.",
|
||||||
|
action_required: false,
|
||||||
|
unread: false,
|
||||||
|
age_days: 12,
|
||||||
|
attachments: 0,
|
||||||
|
sla_hours: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e18",
|
||||||
|
received_at: "2026-05-08T16:30:00Z",
|
||||||
|
from: { name: "Регулятор (Еврокомиссия)", email: "ec-comm@eu.regulator", title: "Официальное уведомление" },
|
||||||
|
subject: "Информационный запрос — соответствие EU AI Act (статья 13)",
|
||||||
|
preview: "Формальный запрос информации по статье 13 EU AI Act. Дедлайн ответа: 5 июня 2026.",
|
||||||
|
summary: "Формальный запрос Еврокомиссии по EU AI Act, ст. 13. Жёсткий дедлайн 5 июня. Legal + Compliance готовят ответ.",
|
||||||
|
category: "Legal",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 97,
|
||||||
|
recommendation: "Escalate",
|
||||||
|
ai_rationale: "Формальный регуляторный запрос с законодательным дедлайном. Рекомендую: CEO в курсе, Legal ведёт, уведомить Совет. Штрафные риски значительные.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 14,
|
||||||
|
attachments: 2,
|
||||||
|
sla_hours: 96
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
briefing_text:
|
||||||
|
"Доброе утро. Сегодня 22 мая 2026 г.\n\n" +
|
||||||
|
"В работе 18 писем за последние 14 дней. AI-приоритизация выделила:\n\n" +
|
||||||
|
"• 5 критических (P1) — включая инцидент безопасности SecOps (svc-domino-admin) и формальный запрос регулятора EU AI Act с дедлайном 5 июня.\n" +
|
||||||
|
"• 4 ожидают подписи — CAPEX Q2 ($42 млн), бюджетный резерв FY26, ESG-отчёт, продление MSA TechCo.\n" +
|
||||||
|
"• 3 материала к заседанию Совета 24 мая (M&A «Сокол», риск-регистр, ESG).\n\n" +
|
||||||
|
"Рекомендую начать день с инцидента SecOps (P1, окно SLA <4 ч) и сразу подписать CAPEX-меморандум Председателя — голосование Совета через 2 дня."
|
||||||
|
};
|
||||||
19
executive-os/icon.svg
Normal file
19
executive-os/icon.svg
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#7bb7ff"/>
|
||||||
|
<stop offset="100%" stop-color="#5cf2a0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#121620"/>
|
||||||
|
<stop offset="100%" stop-color="#0a0c10"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<rect x="80" y="120" width="352" height="56" rx="14" fill="url(#g)"/>
|
||||||
|
<rect x="80" y="208" width="240" height="40" rx="10" fill="#7bb7ff" opacity="0.45"/>
|
||||||
|
<rect x="80" y="268" width="300" height="40" rx="10" fill="#7bb7ff" opacity="0.30"/>
|
||||||
|
<rect x="80" y="328" width="180" height="40" rx="10" fill="#7bb7ff" opacity="0.20"/>
|
||||||
|
<circle cx="402" cy="370" r="44" fill="#5cf2a0"/>
|
||||||
|
<path d="M385 370 l14 14 l30 -30" stroke="#0a0c10" stroke-width="10" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
193
executive-os/index.html
Normal file
193
executive-os/index.html
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ExecutiveOS AI — Помощник для руководителя (Lotus)</title>
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="icon.svg" />
|
||||||
|
<meta name="theme-color" content="#7bb7ff" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ExecutiveOS" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="logo-dot"></span>
|
||||||
|
<span><strong>ExecutiveOS</strong> <span class="muted">AI</span></span>
|
||||||
|
</div>
|
||||||
|
<nav class="nav" id="nav">
|
||||||
|
<button data-route="briefing" class="active">Брифинг</button>
|
||||||
|
<button data-route="inbox">Входящие</button>
|
||||||
|
<button data-route="approvals">Согласования</button>
|
||||||
|
<button data-route="escalations">Эскалации</button>
|
||||||
|
<button data-route="search">AI-поиск</button>
|
||||||
|
</nav>
|
||||||
|
<div class="user">
|
||||||
|
<span id="online-dot" class="status-dot"></span>
|
||||||
|
<span class="mono">ceo@acme.corp</span>
|
||||||
|
<span class="muted small">· HCL Notes</span>
|
||||||
|
<button id="btn-install" class="btn-ghost install-btn" hidden>↓ Установить</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="view"></main>
|
||||||
|
|
||||||
|
<!-- Брифинг -->
|
||||||
|
<template id="tpl-briefing">
|
||||||
|
<section>
|
||||||
|
<div class="row spread wrap">
|
||||||
|
<div>
|
||||||
|
<h1>Ежедневный брифинг</h1>
|
||||||
|
<div class="muted small" id="brief-date"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row gap">
|
||||||
|
<button id="btn-refresh" class="btn-ghost">↻ Синхронизация с Lotus</button>
|
||||||
|
<button id="btn-mark-read" class="btn-ghost">Отметить все просмотренными</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="card kpi"><div class="kpi-label">Критических непрочитанных</div><div class="kpi-value danger" data-k="critical"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Ожидают решения</div><div class="kpi-value warn" data-k="pending"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">К Совету директоров</div><div class="kpi-value" data-k="board"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Нарушений SLA</div><div class="kpi-value danger" data-k="sla"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2-1">
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Сводка дня</div>
|
||||||
|
<div id="brief-summary" class="briefing"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Топ-3 приоритета</div>
|
||||||
|
<ol id="brief-top3" class="priorities"></ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">AI-копилот</div>
|
||||||
|
<div class="copilot">
|
||||||
|
<div class="row gap">
|
||||||
|
<input id="cp-input" placeholder="«Что требует моей подписи?», «Покажи просроченные»…" />
|
||||||
|
<button id="cp-ask" class="btn-primary">Спросить</button>
|
||||||
|
</div>
|
||||||
|
<div id="cp-suggest" class="chips"></div>
|
||||||
|
<div id="cp-answer" class="answer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Входящие -->
|
||||||
|
<template id="tpl-inbox">
|
||||||
|
<section>
|
||||||
|
<h1>Приоритетные входящие</h1>
|
||||||
|
<div class="card row gap wrap">
|
||||||
|
<input id="inbox-search" placeholder="Поиск по теме, отправителю, тексту…" />
|
||||||
|
<select id="inbox-cat">
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
</select>
|
||||||
|
<select id="inbox-pri">
|
||||||
|
<option value="">Все приоритеты</option>
|
||||||
|
<option value="P1">P1 — Критический</option>
|
||||||
|
<option value="P2">P2 — Высокий</option>
|
||||||
|
<option value="P3">P3 — Обычный</option>
|
||||||
|
</select>
|
||||||
|
<label class="row gap small"><input type="checkbox" id="inbox-unread" /> <span>Только непрочитанные</span></label>
|
||||||
|
</div>
|
||||||
|
<div id="inbox-list" class="email-list"></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Согласования -->
|
||||||
|
<template id="tpl-approvals">
|
||||||
|
<section>
|
||||||
|
<div class="row spread wrap">
|
||||||
|
<h1>Ожидают согласования</h1>
|
||||||
|
<div class="muted small">AI оценил риск и предложил действие. Решение всегда за вами.</div>
|
||||||
|
</div>
|
||||||
|
<div id="appr-list" class="approval-list"></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Эскалации -->
|
||||||
|
<template id="tpl-escalations">
|
||||||
|
<section>
|
||||||
|
<h1>Монитор эскалаций</h1>
|
||||||
|
<div class="card">
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Возраст</th><th>Тема</th><th>От кого</th><th>Должность</th>
|
||||||
|
<th class="r">SLA</th><th>Статус</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="esc-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Поиск -->
|
||||||
|
<template id="tpl-search">
|
||||||
|
<section>
|
||||||
|
<h1>AI-поиск по корпоративной почте</h1>
|
||||||
|
<div class="card stack">
|
||||||
|
<div class="row gap">
|
||||||
|
<input id="srch-input" placeholder="Семантический поиск по письмам, согласованиям, материалам Совета…" />
|
||||||
|
<button id="srch-go" class="btn-primary">Найти</button>
|
||||||
|
</div>
|
||||||
|
<div class="chips" id="srch-suggest"></div>
|
||||||
|
</div>
|
||||||
|
<div id="srch-results" class="email-list"></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
<script>
|
||||||
|
// ---- PWA: регистрация Service Worker ----
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("./sw.js")
|
||||||
|
.then(reg => console.log("[PWA] SW registered:", reg.scope))
|
||||||
|
.catch(err => console.error("[PWA] SW error:", err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Install prompt ----
|
||||||
|
let deferredPrompt = null;
|
||||||
|
const installBtn = document.getElementById("btn-install");
|
||||||
|
window.addEventListener("beforeinstallprompt", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
installBtn.hidden = false;
|
||||||
|
});
|
||||||
|
installBtn.addEventListener("click", async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
console.log("[PWA] install outcome:", outcome);
|
||||||
|
deferredPrompt = null;
|
||||||
|
installBtn.hidden = true;
|
||||||
|
});
|
||||||
|
window.addEventListener("appinstalled", () => {
|
||||||
|
installBtn.hidden = true;
|
||||||
|
console.log("[PWA] installed");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Online/offline индикатор ----
|
||||||
|
const dot = document.getElementById("online-dot");
|
||||||
|
function updateOnline() {
|
||||||
|
const online = navigator.onLine;
|
||||||
|
dot.classList.toggle("offline", !online);
|
||||||
|
dot.title = online ? "Online — Lotus синхронизирован" : "Offline — данные из кэша";
|
||||||
|
}
|
||||||
|
window.addEventListener("online", updateOnline);
|
||||||
|
window.addEventListener("offline", updateOnline);
|
||||||
|
updateOnline();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
executive-os/manifest.webmanifest
Normal file
33
executive-os/manifest.webmanifest
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "ExecutiveOS AI — Lotus Copilot",
|
||||||
|
"short_name": "ExecutiveOS",
|
||||||
|
"description": "AI Chief of Staff для руководителя. Интеграция с HCL Notes / Lotus Notes.",
|
||||||
|
"lang": "ru",
|
||||||
|
"start_url": "./index.html",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#0a0c10",
|
||||||
|
"theme_color": "#7bb7ff",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "business"],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Брифинг",
|
||||||
|
"url": "./index.html#briefing",
|
||||||
|
"description": "Ежедневный AI-брифинг"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Согласования",
|
||||||
|
"url": "./index.html#approvals",
|
||||||
|
"description": "Ожидают решения"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Эскалации",
|
||||||
|
"url": "./index.html#escalations",
|
||||||
|
"description": "Критические P1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
285
executive-os/styles.css
Normal file
285
executive-os/styles.css
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #0a0c10;
|
||||||
|
--panel: #121620;
|
||||||
|
--panel-2: #181d29;
|
||||||
|
--line: #1e2535;
|
||||||
|
--muted: #7a8699;
|
||||||
|
--text: #e7ecf3;
|
||||||
|
--accent: #7bb7ff;
|
||||||
|
--accent-2: #5cf2a0;
|
||||||
|
--warn: #ffb454;
|
||||||
|
--danger: #ff5c7a;
|
||||||
|
--urgent: #ff3d65;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
background: var(--ink); color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-size: 14px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.accent { color: var(--accent); }
|
||||||
|
.warn { color: var(--warn); }
|
||||||
|
.danger { color: var(--danger); }
|
||||||
|
.small { font-size: 12px; }
|
||||||
|
.r { text-align: right; }
|
||||||
|
.wrap { flex-wrap: wrap; gap: 12px; }
|
||||||
|
.spread { justify-content: space-between; align-items: baseline; }
|
||||||
|
.gap { gap: 12px; }
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 24px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(10, 12, 16, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-size: 16px; letter-spacing: -0.01em; }
|
||||||
|
.logo-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
border-radius: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.nav { display: flex; gap: 4px; }
|
||||||
|
.nav button {
|
||||||
|
background: transparent; border: 1px solid transparent;
|
||||||
|
color: var(--muted); padding: 6px 12px; border-radius: 6px;
|
||||||
|
font-size: 13px; cursor: pointer; transition: 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.nav button:hover { color: var(--text); }
|
||||||
|
.nav button.active { background: var(--panel); border-color: var(--line); color: var(--text); }
|
||||||
|
.user {
|
||||||
|
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||||
|
color: var(--muted); font-size: 12px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--accent-2);
|
||||||
|
box-shadow: 0 0 8px var(--accent-2);
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.status-dot.offline {
|
||||||
|
background: var(--warn);
|
||||||
|
box-shadow: 0 0 8px var(--warn);
|
||||||
|
}
|
||||||
|
.install-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
main { max-width: 1280px; margin: 0 auto; padding: 32px 28px; }
|
||||||
|
section { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
|
||||||
|
|
||||||
|
.row { display: flex; align-items: center; }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI */
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid; gap: 16px;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
.kpi { padding: 18px 20px; }
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 28px; font-weight: 600; margin-top: 8px;
|
||||||
|
font-variant-numeric: tabular-nums; letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2-1 {
|
||||||
|
display: grid; gap: 16px;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) { .grid-2-1 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Briefing */
|
||||||
|
.briefing {
|
||||||
|
margin-top: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.priorities {
|
||||||
|
margin: 12px 0 0; padding: 0; list-style: none;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.priorities li {
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; padding: 12px;
|
||||||
|
}
|
||||||
|
.priorities .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
input[type="text"], input:not([type]), select {
|
||||||
|
background: var(--ink); border: 1px solid var(--line); color: var(--text);
|
||||||
|
padding: 8px 12px; border-radius: 6px; font-size: 13px;
|
||||||
|
font-family: inherit; outline: none;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { border-color: var(--accent); }
|
||||||
|
input { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
button.btn-primary {
|
||||||
|
background: var(--accent); color: var(--ink); border: 0;
|
||||||
|
padding: 8px 16px; border-radius: 6px; font-weight: 600;
|
||||||
|
font-size: 13px; cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
button.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
button.btn-ghost {
|
||||||
|
background: transparent; border: 1px solid var(--line); color: var(--text);
|
||||||
|
padding: 6px 14px; border-radius: 6px; font-size: 13px;
|
||||||
|
cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
button.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Copilot */
|
||||||
|
.copilot { margin-top: 10px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.chip {
|
||||||
|
background: transparent; border: 1px solid var(--line); color: var(--muted);
|
||||||
|
font-size: 11px; padding: 4px 10px; border-radius: 999px;
|
||||||
|
cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.answer {
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; padding: 14px 16px;
|
||||||
|
font-size: 13px; line-height: 1.7;
|
||||||
|
white-space: pre-wrap; display: none;
|
||||||
|
}
|
||||||
|
.answer.show { display: block; }
|
||||||
|
|
||||||
|
/* Email list */
|
||||||
|
.email-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.email {
|
||||||
|
background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; padding: 14px 16px;
|
||||||
|
display: grid; grid-template-columns: 60px 1fr auto; gap: 14px;
|
||||||
|
align-items: start; cursor: pointer; transition: 0.15s;
|
||||||
|
}
|
||||||
|
.email:hover { border-color: var(--accent); }
|
||||||
|
.email.unread { border-left: 3px solid var(--accent); }
|
||||||
|
.email.urgent { border-left: 3px solid var(--urgent); }
|
||||||
|
.email .pri-badge {
|
||||||
|
display: inline-block; text-align: center;
|
||||||
|
padding: 4px 8px; font-size: 11px; font-weight: 600;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.pri-P1 { color: var(--urgent); border-color: var(--urgent) !important; }
|
||||||
|
.pri-P2 { color: var(--warn); border-color: var(--warn) !important; }
|
||||||
|
.pri-P3 { color: var(--muted); }
|
||||||
|
.email .meta { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.email .subject { font-weight: 500; }
|
||||||
|
.email .from { color: var(--muted); font-size: 12px; }
|
||||||
|
.email .preview { color: var(--muted); font-size: 12px; margin-top: 4px; line-height: 1.5; }
|
||||||
|
.email .tags { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||||
|
.tag {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
padding: 2px 8px; border-radius: 3px;
|
||||||
|
background: var(--ink); border: 1px solid var(--line); color: var(--muted);
|
||||||
|
}
|
||||||
|
.tag.cat-approval { color: var(--warn); border-color: var(--warn); }
|
||||||
|
.tag.cat-board { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.tag.cat-escalation, .tag.cat-urgent { color: var(--urgent); border-color: var(--urgent); }
|
||||||
|
.tag.cat-financial, .tag.cat-legal, .tag.cat-hr { color: var(--accent-2); border-color: var(--accent-2); }
|
||||||
|
.tag.cat-informational { color: var(--muted); }
|
||||||
|
.email .actions {
|
||||||
|
display: flex; flex-direction: column; align-items: flex-end; gap: 6px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.email .age { font-size: 11px; color: var(--muted); font-family: ui-monospace, monospace; }
|
||||||
|
.email .reco {
|
||||||
|
font-size: 11px; padding: 4px 10px; border-radius: 999px;
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.reco-approve { color: var(--accent-2); border-color: var(--accent-2); }
|
||||||
|
.reco-sign { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.reco-vote { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.reco-review { color: var(--warn); border-color: var(--warn); }
|
||||||
|
.reco-reject { color: var(--danger); border-color: var(--danger); }
|
||||||
|
.reco-escalate{ color: var(--urgent); border-color: var(--urgent); }
|
||||||
|
.reco-delegate{ color: var(--muted); }
|
||||||
|
.reco-ignore { color: var(--muted); }
|
||||||
|
|
||||||
|
.score-bar {
|
||||||
|
width: 100%; height: 4px; background: var(--ink);
|
||||||
|
border-radius: 2px; margin-top: 6px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.score-fill {
|
||||||
|
height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Approval cards */
|
||||||
|
.approval-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.approval {
|
||||||
|
background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 12px; padding: 18px 20px;
|
||||||
|
display: grid; gap: 16px;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) { .approval { grid-template-columns: 1fr; } }
|
||||||
|
.approval .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.approval .subject { font-weight: 600; font-size: 15px; }
|
||||||
|
.approval .meta-row {
|
||||||
|
margin-top: 8px; color: var(--muted); font-size: 12px;
|
||||||
|
display: flex; gap: 14px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.approval .summary { margin-top: 12px; font-size: 13px; line-height: 1.6; color: var(--text); }
|
||||||
|
.approval .ai-box {
|
||||||
|
margin-top: 12px; padding: 12px 14px;
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.approval .ai-box strong { color: var(--accent); }
|
||||||
|
.approval .controls {
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
min-width: 160px; align-items: stretch;
|
||||||
|
}
|
||||||
|
.approval .controls button {
|
||||||
|
font-family: inherit; font-size: 13px;
|
||||||
|
padding: 8px 12px; border-radius: 6px; cursor: pointer;
|
||||||
|
background: var(--ink); border: 1px solid var(--line); color: var(--text);
|
||||||
|
}
|
||||||
|
.approval .controls .btn-approve { background: var(--accent-2); color: var(--ink); border: 0; font-weight: 600; }
|
||||||
|
.approval .controls .btn-reject { color: var(--danger); border-color: var(--danger); }
|
||||||
|
.approval .controls .btn-delegate{ color: var(--muted); }
|
||||||
|
.approval .controls .btn-sign { background: var(--accent); color: var(--ink); border: 0; font-weight: 600; }
|
||||||
|
.approval.resolved { opacity: 0.55; }
|
||||||
|
.approval .resolved-tag { color: var(--accent-2); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table.data {
|
||||||
|
width: 100%; border-collapse: collapse; margin-top: 6px; font-size: 13px;
|
||||||
|
}
|
||||||
|
table.data th {
|
||||||
|
color: var(--muted); font-weight: 500;
|
||||||
|
text-transform: uppercase; font-size: 11px; letter-spacing: 0.08em;
|
||||||
|
text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
table.data td {
|
||||||
|
padding: 10px 6px; border-bottom: 1px solid var(--line);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
table.data tr:last-child td { border-bottom: 0; }
|
||||||
56
executive-os/sw.js
Normal file
56
executive-os/sw.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// ExecutiveOS AI — Service Worker (PWA cache + offline)
|
||||||
|
const CACHE_NAME = "executive-os-v1";
|
||||||
|
const APP_SHELL = [
|
||||||
|
"./",
|
||||||
|
"./index.html",
|
||||||
|
"./styles.css",
|
||||||
|
"./app.js",
|
||||||
|
"./data.js",
|
||||||
|
"./manifest.webmanifest",
|
||||||
|
"./icon.svg"
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((names) =>
|
||||||
|
Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache-first для app shell, network-first для прочего.
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const isShell = APP_SHELL.some((p) => url.pathname.endsWith(p.replace("./", "/")) || url.pathname.endsWith(p.replace("./", "")));
|
||||||
|
|
||||||
|
if (isShell) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then((cached) => cached || fetch(req).then((res) => {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE_NAME).then((c) => c.put(req, copy));
|
||||||
|
return res;
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network-first fallback for everything else (e.g. future /api/* calls)
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req).then((res) => {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE_NAME).then((c) => c.put(req, copy));
|
||||||
|
return res;
|
||||||
|
}).catch(() => caches.match(req))
|
||||||
|
);
|
||||||
|
});
|
||||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_BASE=http://localhost:8000
|
||||||
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.env*.local
|
||||||
|
*.log
|
||||||
106
frontend/app/cashflow/page.tsx
Normal file
106
frontend/app/cashflow/page.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { fetcher, fmtMoney } from "@/lib/api";
|
||||||
|
import type { CashFlow, DividendSummary, SpendingSummary } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function CashFlowPage() {
|
||||||
|
const cf = useSWR<CashFlow>("/cashflow/summary", fetcher);
|
||||||
|
const sp = useSWR<SpendingSummary>("/cashflow/spending", fetcher);
|
||||||
|
const dv = useSWR<DividendSummary>("/cashflow/dividends", fetcher);
|
||||||
|
|
||||||
|
if (cf.isLoading || sp.isLoading || dv.isLoading) return <div className="text-muted">Loading…</div>;
|
||||||
|
if (!cf.data || !sp.data || !dv.data) return <div className="card text-danger">Failed to load.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Cash Flow</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Stat label="Inflows" value={fmtMoney(cf.data.inflows)} colorClass="text-accent" />
|
||||||
|
<Stat label="Outflows" value={fmtMoney(cf.data.outflows)} colorClass="text-danger" />
|
||||||
|
<Stat
|
||||||
|
label="Net"
|
||||||
|
value={fmtMoney(cf.data.net)}
|
||||||
|
colorClass={parseFloat(cf.data.net) >= 0 ? "text-accent" : "text-danger"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div className="card">
|
||||||
|
<div className="kpi-sub mb-3">Spending by Category</div>
|
||||||
|
<BarList data={sp.data.by_category} tone="danger" />
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="kpi-sub mb-3">Dividends & Coupons by Asset</div>
|
||||||
|
<BarList data={dv.data.by_asset} tone="accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="kpi-sub mb-3">Monthly Cash Flow</div>
|
||||||
|
<MonthlyChart data={cf.data.monthly} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, colorClass = "" }: { label: string; value: string; colorClass?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="kpi-sub">{label}</div>
|
||||||
|
<div className={`kpi mt-2 ${colorClass}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarList({ data, tone }: { data: Record<string, string>; tone: "accent" | "danger" }) {
|
||||||
|
const rows = Object.entries(data).map(([k, v]) => ({ k, v: parseFloat(v) }));
|
||||||
|
if (rows.length === 0) return <div className="text-muted text-sm">No data.</div>;
|
||||||
|
const max = Math.max(...rows.map((r) => r.v));
|
||||||
|
rows.sort((a, b) => b.v - a.v);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.k}>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-muted">{r.k}</span>
|
||||||
|
<span className="font-mono">{fmtMoney(r.v)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded bg-ink overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={tone === "accent" ? "bg-accent h-full" : "bg-danger h-full"}
|
||||||
|
style={{ width: `${(r.v / max) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MonthlyChart({ data }: { data: { month: string; inflow: number; outflow: number }[] }) {
|
||||||
|
if (data.length === 0) return <div className="text-muted text-sm">No data.</div>;
|
||||||
|
const max = Math.max(...data.map((d) => Math.max(d.inflow, d.outflow)));
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-2 h-40">
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.month} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div className="w-full flex gap-0.5 items-end h-full">
|
||||||
|
<div
|
||||||
|
className="flex-1 bg-accent rounded-t"
|
||||||
|
style={{ height: `${(d.inflow / max) * 100}%` }}
|
||||||
|
title={`In ${fmtMoney(d.inflow)}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex-1 bg-danger rounded-t"
|
||||||
|
style={{ height: `${(d.outflow / max) * 100}%` }}
|
||||||
|
title={`Out ${fmtMoney(d.outflow)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted font-mono">{d.month.slice(2)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/app/globals.css
Normal file
25
frontend/app/globals.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
background: #0b0d10;
|
||||||
|
color: #e6ebf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-panel border border-line rounded-xl p-5;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
@apply text-3xl font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
.kpi-sub {
|
||||||
|
@apply text-xs uppercase tracking-widest text-muted;
|
||||||
|
}
|
||||||
|
.table-row {
|
||||||
|
@apply border-b border-line;
|
||||||
|
}
|
||||||
19
frontend/app/layout.tsx
Normal file
19
frontend/app/layout.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import "./globals.css";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Nav } from "@/components/Nav";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Atlas AI Wealth",
|
||||||
|
description: "AI-native wealth intelligence — portfolio, cash flow, forecasts."
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="font-sans">
|
||||||
|
<Nav />
|
||||||
|
<main className="max-w-7xl mx-auto px-6 py-8">{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/app/page.tsx
Normal file
91
frontend/app/page.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { fetcher, fmtMoney, fmtPct } from "@/lib/api";
|
||||||
|
import type { Dashboard } from "@/lib/types";
|
||||||
|
import { Copilot } from "@/components/Copilot";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data, error, isLoading } = useSWR<Dashboard>("/dashboard", fetcher);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-muted">Loading…</div>;
|
||||||
|
if (error) return <ErrorState />;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const perfColor = (data.performance_ytd ?? 0) >= 0 ? "text-accent" : "text-danger";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||||
|
<div className="text-xs text-muted font-mono">Wealth OS — institutional view</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Kpi label="Net Worth" value={fmtMoney(data.net_worth)} />
|
||||||
|
<Kpi label="Portfolio Value" value={fmtMoney(data.portfolio_value)} />
|
||||||
|
<Kpi label="Cash" value={fmtMoney(data.cash_balance)} />
|
||||||
|
<Kpi label="Performance YTD" value={fmtPct(data.performance_ytd)} colorClass={perfColor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Kpi label="Spending MTD" value={fmtMoney(data.spending_mtd)} />
|
||||||
|
<Kpi label="Spending YTD" value={fmtMoney(data.spending_ytd)} />
|
||||||
|
<Kpi label="Passive Income YTD" value={fmtMoney(data.passive_income_ytd)} colorClass="text-accent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div className="card lg:col-span-2">
|
||||||
|
<div className="kpi-sub mb-3">Top Positions</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-muted text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2">Asset</th>
|
||||||
|
<th className="text-right py-2">Qty</th>
|
||||||
|
<th className="text-right py-2">Avg Cost</th>
|
||||||
|
<th className="text-right py-2">Price</th>
|
||||||
|
<th className="text-right py-2">Market Value</th>
|
||||||
|
<th className="text-right py-2">PnL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.top_positions.map((p) => (
|
||||||
|
<tr key={p.asset} className="table-row">
|
||||||
|
<td className="py-2 font-mono">{p.asset}</td>
|
||||||
|
<td className="text-right">{parseFloat(p.quantity).toFixed(2)}</td>
|
||||||
|
<td className="text-right">{fmtMoney(p.avg_cost)}</td>
|
||||||
|
<td className="text-right">{fmtMoney(p.market_price)}</td>
|
||||||
|
<td className="text-right">{fmtMoney(p.market_value)}</td>
|
||||||
|
<td className={`text-right ${parseFloat(p.unrealized_pnl) >= 0 ? "text-accent" : "text-danger"}`}>
|
||||||
|
{fmtMoney(p.unrealized_pnl)} ({fmtPct(p.unrealized_pnl_pct, 1)})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Copilot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({ label, value, colorClass = "" }: { label: string; value: string; colorClass?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="kpi-sub">{label}</div>
|
||||||
|
<div className={`kpi mt-2 ${colorClass}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState() {
|
||||||
|
return (
|
||||||
|
<div className="card text-muted">
|
||||||
|
Backend unreachable. Start it with{" "}
|
||||||
|
<code className="font-mono text-text">.venv/bin/uvicorn app.main:app --port 8000</code> from{" "}
|
||||||
|
<code className="font-mono text-text">backend/</code>, then upload a statement at{" "}
|
||||||
|
<a className="text-accent underline" href="/upload">/upload</a>.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/app/portfolio/page.tsx
Normal file
86
frontend/app/portfolio/page.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { fetcher, fmtMoney, fmtPct } from "@/lib/api";
|
||||||
|
import type { PortfolioMetrics } from "@/lib/types";
|
||||||
|
import { AllocationBar } from "@/components/AllocationBar";
|
||||||
|
|
||||||
|
export default function PortfolioPage() {
|
||||||
|
const { data, error, isLoading } = useSWR<PortfolioMetrics>("/portfolio/metrics", fetcher);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-muted">Loading…</div>;
|
||||||
|
if (error) return <div className="card text-danger">Failed to load portfolio.</div>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Portfolio</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Stat label="Market Value" value={fmtMoney(data.total_market_value)} />
|
||||||
|
<Stat label="Cost Basis" value={fmtMoney(data.total_cost_basis)} />
|
||||||
|
<Stat
|
||||||
|
label="Unrealized PnL"
|
||||||
|
value={`${fmtMoney(data.unrealized_pnl)} (${fmtPct(data.unrealized_pnl_pct, 2)})`}
|
||||||
|
colorClass={parseFloat(data.unrealized_pnl) >= 0 ? "text-accent" : "text-danger"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Realized PnL"
|
||||||
|
value={fmtMoney(data.realized_pnl)}
|
||||||
|
colorClass={parseFloat(data.realized_pnl) >= 0 ? "text-accent" : "text-danger"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Stat label="TWR YTD" value={fmtPct(data.twr_ytd)} />
|
||||||
|
<Stat label="TWR Since Inception" value={fmtPct(data.twr_since_inception)} />
|
||||||
|
<Stat label="IRR Since Inception" value={fmtPct(data.irr_since_inception)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div className="card lg:col-span-2">
|
||||||
|
<div className="kpi-sub mb-3">Holdings</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-muted text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2">Asset</th>
|
||||||
|
<th className="text-right py-2">Qty</th>
|
||||||
|
<th className="text-right py-2">Avg Cost</th>
|
||||||
|
<th className="text-right py-2">Price</th>
|
||||||
|
<th className="text-right py-2">Market Value</th>
|
||||||
|
<th className="text-right py-2">PnL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.positions.map((p) => (
|
||||||
|
<tr key={p.asset} className="table-row">
|
||||||
|
<td className="py-2 font-mono">{p.asset}</td>
|
||||||
|
<td className="text-right">{parseFloat(p.quantity).toFixed(2)}</td>
|
||||||
|
<td className="text-right">{fmtMoney(p.avg_cost)}</td>
|
||||||
|
<td className="text-right">{fmtMoney(p.market_price)}</td>
|
||||||
|
<td className="text-right">{fmtMoney(p.market_value)}</td>
|
||||||
|
<td className={`text-right ${parseFloat(p.unrealized_pnl) >= 0 ? "text-accent" : "text-danger"}`}>
|
||||||
|
{fmtMoney(p.unrealized_pnl)} ({fmtPct(p.unrealized_pnl_pct, 1)})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="kpi-sub mb-3">Allocation</div>
|
||||||
|
<AllocationBar allocation={data.allocation} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, colorClass = "" }: { label: string; value: string; colorClass?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="kpi-sub">{label}</div>
|
||||||
|
<div className={`kpi mt-2 ${colorClass}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/app/transactions/page.tsx
Normal file
99
frontend/app/transactions/page.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { fetcher, fmtMoney } from "@/lib/api";
|
||||||
|
import type { Transaction, TransactionType } from "@/lib/types";
|
||||||
|
|
||||||
|
const TYPES: TransactionType[] = [
|
||||||
|
"BUY", "SELL", "DIVIDEND", "COUPON", "DEPOSIT",
|
||||||
|
"WITHDRAWAL", "SPENDING", "TRANSFER", "FX", "FEE", "UNKNOWN"
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLORS: Record<TransactionType, string> = {
|
||||||
|
BUY: "text-warn", SELL: "text-accent", DIVIDEND: "text-accent",
|
||||||
|
COUPON: "text-accent", DEPOSIT: "text-accent", WITHDRAWAL: "text-danger",
|
||||||
|
SPENDING: "text-danger", TRANSFER: "text-muted", FX: "text-muted",
|
||||||
|
FEE: "text-danger", UNKNOWN: "text-muted"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TransactionsPage() {
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TransactionType | "">("");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (typeFilter) params.set("transaction_type", typeFilter);
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
const path = `/transactions${params.toString() ? `?${params}` : ""}`;
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useSWR<Transaction[]>(path, fetcher);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c: Partial<Record<TransactionType, number>> = {};
|
||||||
|
(data || []).forEach((t) => { c[t.transaction_type] = (c[t.transaction_type] || 0) + 1; });
|
||||||
|
return c;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Transactions</h1>
|
||||||
|
|
||||||
|
<div className="card flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search description…"
|
||||||
|
className="flex-1 min-w-[200px] bg-ink border border-line rounded-md px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value as TransactionType | "")}
|
||||||
|
className="bg-ink border border-line rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All types</option>
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}{counts[t] ? ` (${counts[t]})` : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <div className="text-muted">Loading…</div>}
|
||||||
|
{error && <div className="card text-danger">Failed to load.</div>}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-muted text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2">Date</th>
|
||||||
|
<th className="text-left py-2">Type</th>
|
||||||
|
<th className="text-left py-2">Asset</th>
|
||||||
|
<th className="text-right py-2">Qty</th>
|
||||||
|
<th className="text-right py-2">Amount</th>
|
||||||
|
<th className="text-left py-2 pl-3">Category</th>
|
||||||
|
<th className="text-left py-2 pl-3">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((t) => (
|
||||||
|
<tr key={t.id} className="table-row">
|
||||||
|
<td className="py-2 font-mono text-xs">{new Date(t.occurred_at).toISOString().slice(0, 10)}</td>
|
||||||
|
<td className={`py-2 ${COLORS[t.transaction_type]}`}>{t.transaction_type}</td>
|
||||||
|
<td className="py-2 font-mono">{t.asset || "—"}</td>
|
||||||
|
<td className="text-right">{parseFloat(t.quantity) ? parseFloat(t.quantity).toFixed(2) : "—"}</td>
|
||||||
|
<td className={`text-right ${parseFloat(t.amount) >= 0 ? "text-accent" : "text-danger"}`}>
|
||||||
|
{fmtMoney(t.amount, t.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="pl-3 text-muted text-xs">{t.category || "—"}</td>
|
||||||
|
<td className="pl-3 text-muted truncate max-w-xs">{t.description || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && (
|
||||||
|
<tr><td colSpan={7} className="text-center text-muted py-8">No transactions. Upload a statement first.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/app/upload/page.tsx
Normal file
87
frontend/app/upload/page.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { uploadFile } from "@/lib/api";
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
account_id: string;
|
||||||
|
rows_parsed: number;
|
||||||
|
rows_imported: number;
|
||||||
|
rows_skipped: number;
|
||||||
|
detected_broker: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadPage() {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [result, setResult] = useState<UploadResult | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(file);
|
||||||
|
setResult(res);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e.message || String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Upload Statement</h1>
|
||||||
|
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<div className="kpi-sub">Broker statement / bank export</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="block w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:bg-accent file:text-ink file:font-medium hover:file:opacity-90"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Supported: XLSX, XLS, CSV. Parser auto-detects broker, normalizes
|
||||||
|
headers, classifies transactions into BUY / SELL / DIVIDEND / COUPON /
|
||||||
|
SPENDING / TRANSFER / FEE.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!file || busy}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-ink font-medium disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy ? "Uploading…" : "Upload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="card text-danger text-sm">{err}</div>}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="card space-y-2 text-sm">
|
||||||
|
<div className="kpi-sub mb-2">Import Result</div>
|
||||||
|
<Row k="Detected Broker" v={result.detected_broker} />
|
||||||
|
<Row k="Rows Parsed" v={String(result.rows_parsed)} />
|
||||||
|
<Row k="Rows Imported" v={String(result.rows_imported)} accent />
|
||||||
|
<Row k="Rows Skipped (dupes)" v={String(result.rows_skipped)} />
|
||||||
|
<Row k="Account ID" v={<span className="font-mono text-xs">{result.account_id}</span>} />
|
||||||
|
<div className="pt-3 text-muted text-xs">
|
||||||
|
Visit <a href="/" className="text-accent underline">Dashboard</a> to see updated metrics.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ k, v, accent }: { k: string; v: React.ReactNode; accent?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between border-b border-line py-2 last:border-0">
|
||||||
|
<span className="text-muted">{k}</span>
|
||||||
|
<span className={accent ? "text-accent font-semibold" : ""}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/components/AllocationBar.tsx
Normal file
27
frontend/components/AllocationBar.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const COLORS = ["#5cf2a0", "#7bb7ff", "#ffb454", "#ff5c7a", "#c08bff", "#7ad6ff", "#e6ebf0"];
|
||||||
|
|
||||||
|
export function AllocationBar({ allocation }: { allocation: Record<string, number> }) {
|
||||||
|
const entries = Object.entries(allocation).sort((a, b) => b[1] - a[1]);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex h-2 rounded overflow-hidden border border-line">
|
||||||
|
{entries.map(([asset, share], i) => (
|
||||||
|
<div
|
||||||
|
key={asset}
|
||||||
|
style={{ width: `${share * 100}%`, background: COLORS[i % COLORS.length] }}
|
||||||
|
title={`${asset} ${(share * 100).toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{entries.map(([asset, share], i) => (
|
||||||
|
<div key={asset} className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-sm" style={{ background: COLORS[i % COLORS.length] }} />
|
||||||
|
<span className="font-mono text-text">{asset}</span>
|
||||||
|
<span className="text-muted ml-auto">{(share * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/components/Copilot.tsx
Normal file
68
frontend/components/Copilot.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { askCopilot } from "@/lib/api";
|
||||||
|
|
||||||
|
const SUGGESTIONS = [
|
||||||
|
"What's my performance?",
|
||||||
|
"How much did I spend?",
|
||||||
|
"What's my passive income?",
|
||||||
|
"What's my concentration risk?"
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Copilot() {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [a, setA] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function ask(question: string) {
|
||||||
|
setLoading(true);
|
||||||
|
setA(null);
|
||||||
|
try {
|
||||||
|
const res = await askCopilot(question);
|
||||||
|
setA(res.answer);
|
||||||
|
} catch (e: any) {
|
||||||
|
setA(`Error: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="kpi-sub mb-3">AI Copilot</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && q && ask(q)}
|
||||||
|
placeholder="Ask about portfolio, spending, dividends, risk…"
|
||||||
|
className="flex-1 bg-ink border border-line rounded-md px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => q && ask(q)}
|
||||||
|
disabled={!q || loading}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-ink text-sm font-medium disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{loading ? "…" : "Ask"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{SUGGESTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => { setQ(s); ask(s); }}
|
||||||
|
className="text-xs px-2 py-1 rounded-md border border-line text-muted hover:text-text hover:border-accent"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{a && (
|
||||||
|
<div className="mt-4 p-4 rounded-md bg-ink border border-line text-sm leading-relaxed">
|
||||||
|
{a}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/components/Nav.tsx
Normal file
44
frontend/components/Nav.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ href: "/", label: "Dashboard" },
|
||||||
|
{ href: "/portfolio", label: "Portfolio" },
|
||||||
|
{ href: "/transactions", label: "Transactions" },
|
||||||
|
{ href: "/cashflow", label: "Cash Flow" },
|
||||||
|
{ href: "/upload", label: "Upload" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Nav() {
|
||||||
|
const path = usePathname();
|
||||||
|
return (
|
||||||
|
<header className="border-b border-line bg-ink/80 backdrop-blur sticky top-0 z-10">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center gap-8">
|
||||||
|
<div className="font-semibold tracking-tight text-lg">
|
||||||
|
<span className="text-accent">Atlas</span> AI Wealth
|
||||||
|
</div>
|
||||||
|
<nav className="flex gap-1">
|
||||||
|
{NAV.map((n) => {
|
||||||
|
const active = path === n.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={n.href}
|
||||||
|
href={n.href}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm transition ${
|
||||||
|
active
|
||||||
|
? "bg-panel text-text border border-line"
|
||||||
|
: "text-muted hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto text-xs text-muted font-mono">demo@atlas.local</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/lib/api.ts
Normal file
48
frontend/lib/api.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "/api";
|
||||||
|
|
||||||
|
export async function fetcher<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch(`${API_BASE}/upload`, { method: "POST", body: fd });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function askCopilot(question: string) {
|
||||||
|
const res = await fetch(`${API_BASE}/copilot/ask`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ question })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json() as Promise<{ answer: string; context: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtMoney(v: number | string | null | undefined, ccy = "USD"): string {
|
||||||
|
if (v === null || v === undefined) return "—";
|
||||||
|
const n = typeof v === "string" ? parseFloat(v) : v;
|
||||||
|
if (Number.isNaN(n)) return "—";
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: ccy,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtPct(v: number | null | undefined, digits = 2): string {
|
||||||
|
if (v === null || v === undefined) return "—";
|
||||||
|
return `${(v * 100).toFixed(digits)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtNum(v: number | string | null | undefined, digits = 4): string {
|
||||||
|
if (v === null || v === undefined) return "—";
|
||||||
|
const n = typeof v === "string" ? parseFloat(v) : v;
|
||||||
|
if (Number.isNaN(n)) return "—";
|
||||||
|
return n.toLocaleString("en-US", { maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
72
frontend/lib/types.ts
Normal file
72
frontend/lib/types.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
export type TransactionType =
|
||||||
|
| "BUY" | "SELL" | "DIVIDEND" | "COUPON" | "DEPOSIT"
|
||||||
|
| "WITHDRAWAL" | "SPENDING" | "TRANSFER" | "FX" | "FEE" | "UNKNOWN";
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
occurred_at: string;
|
||||||
|
transaction_type: TransactionType;
|
||||||
|
asset: string | null;
|
||||||
|
quantity: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
fee: string;
|
||||||
|
category: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
asset: string;
|
||||||
|
quantity: string;
|
||||||
|
avg_cost: string;
|
||||||
|
market_price: string;
|
||||||
|
market_value: string;
|
||||||
|
unrealized_pnl: string;
|
||||||
|
unrealized_pnl_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioMetrics {
|
||||||
|
total_market_value: string;
|
||||||
|
total_cost_basis: string;
|
||||||
|
unrealized_pnl: string;
|
||||||
|
unrealized_pnl_pct: number;
|
||||||
|
realized_pnl: string;
|
||||||
|
cash_balance: string;
|
||||||
|
twr_ytd: number | null;
|
||||||
|
twr_since_inception: number | null;
|
||||||
|
irr_since_inception: number | null;
|
||||||
|
positions: Position[];
|
||||||
|
allocation: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dashboard {
|
||||||
|
net_worth: string;
|
||||||
|
portfolio_value: string;
|
||||||
|
cash_balance: string;
|
||||||
|
spending_mtd: string;
|
||||||
|
spending_ytd: string;
|
||||||
|
passive_income_ytd: string;
|
||||||
|
performance_ytd: number | null;
|
||||||
|
top_positions: Position[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashFlow {
|
||||||
|
inflows: string;
|
||||||
|
outflows: string;
|
||||||
|
net: string;
|
||||||
|
by_category: Record<string, string>;
|
||||||
|
monthly: { month: string; inflow: number; outflow: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpendingSummary {
|
||||||
|
total: string;
|
||||||
|
by_category: Record<string, string>;
|
||||||
|
monthly: { month: string; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DividendSummary {
|
||||||
|
total: string;
|
||||||
|
by_asset: Record<string, string>;
|
||||||
|
by_month: { month: string; amount: number }[];
|
||||||
|
}
|
||||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
10
frontend/next.config.js
Normal file
10
frontend/next.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const API_TARGET = process.env.API_PROXY_TARGET || "http://localhost:8777";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: { typedRoutes: false },
|
||||||
|
async rewrites() {
|
||||||
|
return [{ source: "/api/:path*", destination: `${API_TARGET}/:path*` }];
|
||||||
|
}
|
||||||
|
};
|
||||||
1687
frontend/package-lock.json
generated
Normal file
1687
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "atlas-ai-wealth-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3000",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.2.35",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"swr": "2.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "20.16.10",
|
||||||
|
"@types/react": "18.3.11",
|
||||||
|
"@types/react-dom": "18.3.0",
|
||||||
|
"autoprefixer": "10.4.20",
|
||||||
|
"postcss": "8.4.47",
|
||||||
|
"tailwindcss": "3.4.14",
|
||||||
|
"typescript": "5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/postcss.config.js
Normal file
3
frontend/postcss.config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} }
|
||||||
|
};
|
||||||
23
frontend/tailwind.config.js
Normal file
23
frontend/tailwind.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ink: "#0b0d10",
|
||||||
|
panel: "#13171c",
|
||||||
|
line: "#1f262e",
|
||||||
|
muted: "#7a8693",
|
||||||
|
text: "#e6ebf0",
|
||||||
|
accent: "#5cf2a0",
|
||||||
|
warn: "#ffb454",
|
||||||
|
danger: "#ff5c7a"
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["-apple-system", "BlinkMacSystemFont", "Inter", "ui-sans-serif", "system-ui"],
|
||||||
|
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
193
index.html
Normal file
193
index.html
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Atlas AI Wealth</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand"><span class="accent">Atlas</span> AI Wealth</div>
|
||||||
|
<nav class="nav" id="nav">
|
||||||
|
<button data-route="dashboard" class="active">Dashboard</button>
|
||||||
|
<button data-route="portfolio">Portfolio</button>
|
||||||
|
<button data-route="transactions">Transactions</button>
|
||||||
|
<button data-route="cashflow">Cash Flow</button>
|
||||||
|
<button data-route="upload">Upload</button>
|
||||||
|
</nav>
|
||||||
|
<div class="user">demo@atlas.local</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="view"></main>
|
||||||
|
|
||||||
|
<!-- Templates -->
|
||||||
|
<template id="tpl-dashboard">
|
||||||
|
<section>
|
||||||
|
<div class="row spread">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="muted mono">Wealth OS — institutional view</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="card kpi"><div class="kpi-label">Net Worth</div><div class="kpi-value" data-k="netWorth"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Portfolio Value</div><div class="kpi-value" data-k="portfolioValue"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Cash</div><div class="kpi-value" data-k="cash"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Performance YTD</div><div class="kpi-value accent" data-k="ytd"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-grid kpi-3">
|
||||||
|
<div class="card kpi"><div class="kpi-label">Spending MTD</div><div class="kpi-value" data-k="spendMtd"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Spending YTD</div><div class="kpi-value" data-k="spendYtd"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Passive Income YTD</div><div class="kpi-value accent" data-k="passive"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2-1">
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Top Positions</div>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Asset</th><th class="r">Qty</th><th class="r">Avg Cost</th>
|
||||||
|
<th class="r">Price</th><th class="r">Market Value</th><th class="r">PnL</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="top-positions"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">AI Copilot</div>
|
||||||
|
<div class="copilot">
|
||||||
|
<div class="row">
|
||||||
|
<input id="copilot-input" placeholder="Спроси про портфель, расходы, риски…" />
|
||||||
|
<button id="copilot-ask" class="btn-primary">Ask</button>
|
||||||
|
</div>
|
||||||
|
<div class="chips" id="copilot-suggest"></div>
|
||||||
|
<div id="copilot-answer" class="answer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-portfolio">
|
||||||
|
<section>
|
||||||
|
<h1>Portfolio</h1>
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="card kpi"><div class="kpi-label">Market Value</div><div class="kpi-value" data-k="mv"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Cost Basis</div><div class="kpi-value" data-k="cost"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Unrealized PnL</div><div class="kpi-value accent" data-k="upnl"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Realized PnL</div><div class="kpi-value accent" data-k="rpnl"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-grid kpi-3">
|
||||||
|
<div class="card kpi"><div class="kpi-label">TWR YTD</div><div class="kpi-value" data-k="twrYtd"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">TWR Since Inception</div><div class="kpi-value" data-k="twr"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">IRR Since Inception</div><div class="kpi-value" data-k="irr"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-grid kpi-3">
|
||||||
|
<div class="card kpi"><div class="kpi-label">Volatility (ann.)</div><div class="kpi-value" data-k="vol"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Sharpe</div><div class="kpi-value" data-k="sharpe"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Max Drawdown</div><div class="kpi-value warn" data-k="dd"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2-1">
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Holdings</div>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Asset</th><th>Sector</th><th class="r">Qty</th><th class="r">Avg Cost</th>
|
||||||
|
<th class="r">Price</th><th class="r">Market Value</th><th class="r">PnL</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="holdings"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Allocation</div>
|
||||||
|
<div id="alloc-bar" class="alloc-bar"></div>
|
||||||
|
<div id="alloc-legend" class="alloc-legend"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-transactions">
|
||||||
|
<section>
|
||||||
|
<h1>Transactions</h1>
|
||||||
|
<div class="card row gap">
|
||||||
|
<input id="tx-search" placeholder="Поиск по описанию…" />
|
||||||
|
<select id="tx-type">
|
||||||
|
<option value="">All types</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card overflow">
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Date</th><th>Type</th><th>Asset</th>
|
||||||
|
<th class="r">Qty</th><th class="r">Amount</th>
|
||||||
|
<th>Category</th><th>Description</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="tx-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-cashflow">
|
||||||
|
<section>
|
||||||
|
<h1>Cash Flow</h1>
|
||||||
|
<div class="kpi-grid kpi-3">
|
||||||
|
<div class="card kpi"><div class="kpi-label">Inflows</div><div class="kpi-value accent" data-k="in"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Outflows</div><div class="kpi-value danger" data-k="out"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Net</div><div class="kpi-value" data-k="net"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-1-1">
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Spending by Category</div>
|
||||||
|
<div id="spend-bars"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Dividends & Coupons by Asset</div>
|
||||||
|
<div id="div-bars"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Monthly Cash Flow</div>
|
||||||
|
<div id="monthly-chart" class="chart"></div>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="dot accent"></i>Inflow</span>
|
||||||
|
<span><i class="dot danger"></i>Outflow</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-upload">
|
||||||
|
<section>
|
||||||
|
<h1>Upload Statement</h1>
|
||||||
|
<div class="card stack">
|
||||||
|
<div class="kpi-label">Брокерский отчет / банковская выписка</div>
|
||||||
|
<input type="file" id="file-input" accept=".xlsx,.csv,.pdf" />
|
||||||
|
<p class="muted small">
|
||||||
|
MVP-демо: реальный парсинг XLSX/CSV/PDF реализован в backend (FastAPI).
|
||||||
|
Здесь кликни <strong>«Load sample data»</strong> чтобы загрузить готовый
|
||||||
|
мок-портфель (Interactive Brokers, 27 транзакций 2024–2026).
|
||||||
|
</p>
|
||||||
|
<div class="row gap">
|
||||||
|
<button class="btn-primary" id="btn-load-sample">Load sample data</button>
|
||||||
|
<button class="btn-ghost" id="btn-reset">Reset data</button>
|
||||||
|
</div>
|
||||||
|
<div id="upload-result"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Supported (TRD §7)</div>
|
||||||
|
<ul class="bullets">
|
||||||
|
<li>XLSX (multi-sheet, custom broker formats, inconsistent headers)</li>
|
||||||
|
<li>CSV</li>
|
||||||
|
<li>PDF statements (Phase 2)</li>
|
||||||
|
</ul>
|
||||||
|
<div class="kpi-label">Auto-detected brokers</div>
|
||||||
|
<ul class="bullets">
|
||||||
|
<li>Interactive Brokers · Freedom · Halyk Finance · Binance · Kaspi · Charles Schwab</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
332
styles.css
Normal file
332
styles.css
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #0b0d10;
|
||||||
|
--panel: #13171c;
|
||||||
|
--line: #1f262e;
|
||||||
|
--muted: #7a8693;
|
||||||
|
--text: #e6ebf0;
|
||||||
|
--accent: #5cf2a0;
|
||||||
|
--accent-dim: #2f8d5a;
|
||||||
|
--warn: #ffb454;
|
||||||
|
--danger: #ff5c7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.accent { color: var(--accent); }
|
||||||
|
.warn { color: var(--warn); }
|
||||||
|
.danger { color: var(--danger); }
|
||||||
|
.small { font-size: 12px; }
|
||||||
|
.r { text-align: right; }
|
||||||
|
.spread { justify-content: space-between; align-items: baseline; }
|
||||||
|
.gap { gap: 12px; }
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(11, 13, 16, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 600; letter-spacing: -0.01em; font-size: 16px; }
|
||||||
|
.nav { display: flex; gap: 4px; }
|
||||||
|
.nav button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.15s;
|
||||||
|
}
|
||||||
|
.nav button:hover { color: var(--text); }
|
||||||
|
.nav button.active {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.user {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
main {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 28px;
|
||||||
|
}
|
||||||
|
section { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
|
||||||
|
|
||||||
|
.row { display: flex; align-items: center; }
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI */
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.kpi-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.kpi-grid, .kpi-3 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
.kpi { padding: 18px 20px; }
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grids */
|
||||||
|
.grid-2-1 {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
}
|
||||||
|
.grid-1-1 {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.grid-2-1, .grid-1-1 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table.data {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
table.data th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
table.data td {
|
||||||
|
padding: 10px 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
table.data tr:last-child td { border-bottom: 0; }
|
||||||
|
table.data td.asset { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||||
|
.overflow { overflow-x: auto; }
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
input[type="text"], input[type="file"], input:not([type]), select {
|
||||||
|
background: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { border-color: var(--accent); }
|
||||||
|
input { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
button.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
button.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
button.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
button.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Copilot */
|
||||||
|
.copilot { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
|
||||||
|
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.chip {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.answer {
|
||||||
|
background: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.answer.show { display: block; }
|
||||||
|
|
||||||
|
/* Allocation */
|
||||||
|
.alloc-bar {
|
||||||
|
display: flex;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.alloc-legend {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.alloc-legend .item { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.alloc-legend .dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.alloc-legend .pct { margin-left: auto; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Bars */
|
||||||
|
.bar-row { margin-bottom: 10px; }
|
||||||
|
.bar-row .meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.bar-row .meta .label { color: var(--muted); }
|
||||||
|
.bar-row .meta .val { font-variant-numeric: tabular-nums; }
|
||||||
|
.bar-track {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--ink);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar-fill { height: 100%; border-radius: 3px; }
|
||||||
|
.bar-fill.danger { background: var(--danger); }
|
||||||
|
.bar-fill.accent { background: var(--accent); }
|
||||||
|
|
||||||
|
/* Monthly chart */
|
||||||
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
height: 180px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.chart .bucket {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chart .stack {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.chart .stack .bar {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
}
|
||||||
|
.chart .bar.accent { background: var(--accent); }
|
||||||
|
.chart .bar.danger { background: var(--danger); }
|
||||||
|
.chart .month {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.legend .dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.legend .dot.accent { background: var(--accent); }
|
||||||
|
.legend .dot.danger { background: var(--danger); }
|
||||||
|
|
||||||
|
.bullets {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bullets li { margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.toast.ok { border-color: var(--accent-dim); }
|
||||||
Reference in New Issue
Block a user