Compare commits
No commits in common. "main" and "gitea-pages" have entirely different histories.
main
...
gitea-page
33
.gitignore
vendored
33
.gitignore
vendored
@ -1,33 +0,0 @@
|
||||
# secrets
|
||||
.env
|
||||
.env.local
|
||||
.jwt-secret
|
||||
credentials*.json
|
||||
secrets.json
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
|
||||
# build artifacts
|
||||
node_modules/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
target/
|
||||
|
||||
# data
|
||||
*.sqlite*
|
||||
*.db
|
||||
*.log
|
||||
|
||||
# OS / IDE
|
||||
.DS_Store
|
||||
.claude/
|
||||
.vscode/
|
||||
.idea/
|
||||
465
PRD.md
465
PRD.md
@ -1,465 +0,0 @@
|
||||
# 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.
|
||||
@ -1,26 +0,0 @@
|
||||
# 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.
|
||||
158
README.md
158
README.md
@ -1,158 +0,0 @@
|
||||
# Atlas AI Wealth — MVP
|
||||
|
||||
AI-native wealth intelligence platform implementing [TRD.md](TRD.md) vertical slice:
|
||||
**Excel upload → parse → classify → portfolio analytics → dashboard**.
|
||||
|
||||
> Original onboarding doc: [README.korabayev.md](README.korabayev.md)
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tech |
|
||||
| ----------- | ------------------------------------------------------- |
|
||||
| Backend | Python 3.12, FastAPI, SQLAlchemy 2, SQLite (dev) |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## TRD Coverage
|
||||
|
||||
| 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
@ -1,351 +0,0 @@
|
||||
# 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.
|
||||
@ -1,505 +0,0 @@
|
||||
// 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);
|
||||
})();
|
||||
@ -1,91 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
};
|
||||
@ -1,193 +0,0 @@
|
||||
<!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>
|
||||
@ -1,332 +0,0 @@
|
||||
: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); }
|
||||
@ -1,3 +0,0 @@
|
||||
DATABASE_URL=sqlite:///./atlas.db
|
||||
CORS_ORIGINS=["http://localhost:3000"]
|
||||
DEFAULT_USER_EMAIL=demo@atlas.local
|
||||
7
backend/.gitignore
vendored
7
backend/.gitignore
vendored
@ -1,7 +0,0 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.env
|
||||
.env.local
|
||||
sample_broker_statement.xlsx
|
||||
@ -1,298 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
"""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"
|
||||
@ -1,13 +0,0 @@
|
||||
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()
|
||||
@ -1,22 +0,0 @@
|
||||
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()
|
||||
@ -1,14 +0,0 @@
|
||||
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
|
||||
@ -1,35 +0,0 @@
|
||||
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"}
|
||||
@ -1,27 +0,0 @@
|
||||
"""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}
|
||||
@ -1,81 +0,0 @@
|
||||
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())
|
||||
@ -1,142 +0,0 @@
|
||||
"""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
|
||||
@ -1,14 +0,0 @@
|
||||
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()
|
||||
@ -1,31 +0,0 @@
|
||||
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)
|
||||
@ -1,91 +0,0 @@
|
||||
"""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"]),
|
||||
},
|
||||
)
|
||||
@ -1,58 +0,0 @@
|
||||
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],
|
||||
)
|
||||
@ -1,56 +0,0 @@
|
||||
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"],
|
||||
)
|
||||
@ -1,30 +0,0 @@
|
||||
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]
|
||||
@ -1,29 +0,0 @@
|
||||
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
|
||||
@ -1,89 +0,0 @@
|
||||
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]
|
||||
@ -1,94 +0,0 @@
|
||||
"""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()
|
||||
)
|
||||
@ -1,10 +0,0 @@
|
||||
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
|
||||
@ -1,69 +0,0 @@
|
||||
"""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()
|
||||
@ -1,445 +0,0 @@
|
||||
// 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);
|
||||
})();
|
||||
@ -1,351 +0,0 @@
|
||||
// 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 дня."
|
||||
};
|
||||
@ -1,19 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1,193 +0,0 @@
|
||||
<!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>
|
||||
@ -1,33 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,440 +0,0 @@
|
||||
: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; }
|
||||
|
||||
/* =========================
|
||||
Mobile / responsive
|
||||
========================= */
|
||||
@media (max-width: 880px) {
|
||||
main { padding: 20px 16px; }
|
||||
h1 { font-size: 19px; }
|
||||
.kpi-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.grid-2-1, .grid-1-1 { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
main { padding: 16px 12px; }
|
||||
section { gap: 14px; }
|
||||
|
||||
/* Topbar: nav скроллится горизонтально, user на новую строку */
|
||||
.topbar {
|
||||
padding: 10px 12px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.brand { font-size: 15px; }
|
||||
.nav {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
.nav button {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.user {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* KPI 2 в ряд + меньшие значения */
|
||||
.kpi { padding: 14px 14px; }
|
||||
.kpi-label { font-size: 10px; letter-spacing: 0.08em; }
|
||||
.kpi-value { font-size: 22px; margin-top: 6px; }
|
||||
.kpi-grid, .kpi-3 { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
|
||||
|
||||
/* Cards: меньше отступы */
|
||||
.card { padding: 14px; border-radius: 10px; }
|
||||
|
||||
/* Touch-friendly inputs (iOS не зумит при 16px) */
|
||||
input[type="text"], input:not([type]), select {
|
||||
font-size: 16px;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.row.gap.wrap > input,
|
||||
.row.gap.wrap > select { flex: 1 1 100%; }
|
||||
.row.gap.wrap { gap: 8px; }
|
||||
|
||||
/* Buttons крупнее */
|
||||
button.btn-primary, button.btn-ghost {
|
||||
min-height: 42px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Email card: всё в колонку */
|
||||
.email {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.email > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.email .score-bar { flex: 1; margin-top: 0; }
|
||||
.email .actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.email .actions .age,
|
||||
.email .actions .reco { flex-shrink: 0; }
|
||||
.email .preview {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Approval card: controls под content, кнопки full-width */
|
||||
.approval { grid-template-columns: 1fr; padding: 16px; }
|
||||
.approval .controls { min-width: 0; }
|
||||
.approval .controls button {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.approval .subject { font-size: 15px; }
|
||||
.approval .meta-row { gap: 10px; font-size: 11px; }
|
||||
.approval .ai-box { font-size: 12px; padding: 10px 12px; }
|
||||
.approval .summary { font-size: 13px; }
|
||||
|
||||
/* Таблицы: горизонтальный скролл */
|
||||
table.data { font-size: 12px; }
|
||||
table.data th, table.data td { padding: 8px 6px; }
|
||||
|
||||
/* Allocation легенда в 1 колонку */
|
||||
.alloc-legend { grid-template-columns: 1fr; }
|
||||
|
||||
/* Monthly chart меньше */
|
||||
.chart { height: 140px; }
|
||||
.chart .month { font-size: 9px; }
|
||||
|
||||
/* Brief: топ-3 чуть компактнее */
|
||||
.priorities li { padding: 10px; font-size: 13px; }
|
||||
.briefing { font-size: 13px; line-height: 1.6; }
|
||||
|
||||
/* Чипсы крупнее (тач) */
|
||||
.chip { font-size: 12px; padding: 6px 12px; min-height: 30px; }
|
||||
|
||||
/* AI-копилот ответ */
|
||||
.answer { font-size: 13px; padding: 12px 14px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* Очень узкие — KPI один в ряд */
|
||||
.kpi-grid, .kpi-3 { grid-template-columns: 1fr !important; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
.user .small { display: none; }
|
||||
.user .mono { font-size: 10px; }
|
||||
.topbar .install-btn { font-size: 11px; padding: 6px 10px; }
|
||||
/* Брифинг: header колонка */
|
||||
.row.spread.wrap { flex-direction: column; align-items: flex-start; }
|
||||
.row.spread.wrap > * { width: 100%; }
|
||||
}
|
||||
|
||||
/* Большие касательные таргеты для тач-устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.nav button, .chip, .approval .controls button,
|
||||
button.btn-primary, button.btn-ghost {
|
||||
min-height: 40px;
|
||||
}
|
||||
/* убираем hover-only фокус-эффекты */
|
||||
.email:hover { border-color: var(--line); }
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
// 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 +0,0 @@
|
||||
NEXT_PUBLIC_API_BASE=http://localhost:8000
|
||||
5
frontend/.gitignore
vendored
5
frontend/.gitignore
vendored
@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.env*.local
|
||||
*.log
|
||||
@ -1,106 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
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
5
frontend/next-env.d.ts
vendored
@ -1,5 +0,0 @@
|
||||
/// <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.
|
||||
@ -1,10 +0,0 @@
|
||||
/** @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
1687
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,27 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} }
|
||||
};
|
||||
@ -1,23 +0,0 @@
|
||||
/** @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: []
|
||||
};
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
155
index.html
155
index.html
@ -298,161 +298,6 @@ table.data td {
|
||||
}
|
||||
table.data tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* =========================
|
||||
Mobile / responsive
|
||||
========================= */
|
||||
@media (max-width: 880px) {
|
||||
main { padding: 20px 16px; }
|
||||
h1 { font-size: 19px; }
|
||||
.kpi-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.grid-2-1, .grid-1-1 { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
main { padding: 16px 12px; }
|
||||
section { gap: 14px; }
|
||||
|
||||
/* Topbar: nav скроллится горизонтально, user на новую строку */
|
||||
.topbar {
|
||||
padding: 10px 12px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.brand { font-size: 15px; }
|
||||
.nav {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
.nav button {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.user {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* KPI 2 в ряд + меньшие значения */
|
||||
.kpi { padding: 14px 14px; }
|
||||
.kpi-label { font-size: 10px; letter-spacing: 0.08em; }
|
||||
.kpi-value { font-size: 22px; margin-top: 6px; }
|
||||
.kpi-grid, .kpi-3 { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
|
||||
|
||||
/* Cards: меньше отступы */
|
||||
.card { padding: 14px; border-radius: 10px; }
|
||||
|
||||
/* Touch-friendly inputs (iOS не зумит при 16px) */
|
||||
input[type="text"], input:not([type]), select {
|
||||
font-size: 16px;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.row.gap.wrap > input,
|
||||
.row.gap.wrap > select { flex: 1 1 100%; }
|
||||
.row.gap.wrap { gap: 8px; }
|
||||
|
||||
/* Buttons крупнее */
|
||||
button.btn-primary, button.btn-ghost {
|
||||
min-height: 42px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Email card: всё в колонку */
|
||||
.email {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.email > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.email .score-bar { flex: 1; margin-top: 0; }
|
||||
.email .actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.email .actions .age,
|
||||
.email .actions .reco { flex-shrink: 0; }
|
||||
.email .preview {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Approval card: controls под content, кнопки full-width */
|
||||
.approval { grid-template-columns: 1fr; padding: 16px; }
|
||||
.approval .controls { min-width: 0; }
|
||||
.approval .controls button {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.approval .subject { font-size: 15px; }
|
||||
.approval .meta-row { gap: 10px; font-size: 11px; }
|
||||
.approval .ai-box { font-size: 12px; padding: 10px 12px; }
|
||||
.approval .summary { font-size: 13px; }
|
||||
|
||||
/* Таблицы: горизонтальный скролл */
|
||||
table.data { font-size: 12px; }
|
||||
table.data th, table.data td { padding: 8px 6px; }
|
||||
|
||||
/* Allocation легенда в 1 колонку */
|
||||
.alloc-legend { grid-template-columns: 1fr; }
|
||||
|
||||
/* Monthly chart меньше */
|
||||
.chart { height: 140px; }
|
||||
.chart .month { font-size: 9px; }
|
||||
|
||||
/* Brief: топ-3 чуть компактнее */
|
||||
.priorities li { padding: 10px; font-size: 13px; }
|
||||
.briefing { font-size: 13px; line-height: 1.6; }
|
||||
|
||||
/* Чипсы крупнее (тач) */
|
||||
.chip { font-size: 12px; padding: 6px 12px; min-height: 30px; }
|
||||
|
||||
/* AI-копилот ответ */
|
||||
.answer { font-size: 13px; padding: 12px 14px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* Очень узкие — KPI один в ряд */
|
||||
.kpi-grid, .kpi-3 { grid-template-columns: 1fr !important; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
.user .small { display: none; }
|
||||
.user .mono { font-size: 10px; }
|
||||
.topbar .install-btn { font-size: 11px; padding: 6px 10px; }
|
||||
/* Брифинг: header колонка */
|
||||
.row.spread.wrap { flex-direction: column; align-items: flex-start; }
|
||||
.row.spread.wrap > * { width: 100%; }
|
||||
}
|
||||
|
||||
/* Большие касательные таргеты для тач-устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.nav button, .chip, .approval .controls button,
|
||||
button.btn-primary, button.btn-ghost {
|
||||
min-height: 40px;
|
||||
}
|
||||
/* убираем hover-only фокус-эффекты */
|
||||
.email:hover { border-color: var(--line); }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Авто-синхронизация локальных изменений в Gitea.
|
||||
# Каждые 5 сек проверяет git status. Если есть изменения:
|
||||
# 1) если изменился executive-os/* → пересобирает index.html (single-file)
|
||||
# 2) git add -A && git commit && git push origin main
|
||||
# Креды берутся из ~/.netrc.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT="$(pwd)"
|
||||
INTERVAL="${INTERVAL:-5}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
|
||||
log() { printf "[auto-sync %s] %s\n" "$(date '+%H:%M:%S')" "$*"; }
|
||||
|
||||
trap 'log "stopped"; exit 0' INT TERM
|
||||
log "watching $ROOT (every ${INTERVAL}s, branch=$BRANCH)"
|
||||
|
||||
while true; do
|
||||
changes="$(git status --porcelain 2>/dev/null || true)"
|
||||
if [ -n "$changes" ]; then
|
||||
log "changes detected"
|
||||
# если затронут executive-os/ — пересобираем single-file index.html
|
||||
if echo "$changes" | grep -qE "executive-os/"; then
|
||||
log "rebuilding single-file index.html"
|
||||
bash "$ROOT/scripts/build-single-file.sh" || log "build failed"
|
||||
fi
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
log "nothing actually staged after add, skipping"
|
||||
else
|
||||
msg="auto-sync $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
git commit -m "$msg" >/dev/null 2>&1 || { log "commit failed"; sleep "$INTERVAL"; continue; }
|
||||
log "committed: $msg"
|
||||
if git push origin "$BRANCH" >/dev/null 2>&1; then
|
||||
log "pushed → origin/$BRANCH"
|
||||
else
|
||||
log "push failed (will retry next tick)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Собирает single-file index.html из executive-os/ для Gitea Pages.
|
||||
# Inline CSS + JS + SVG-иконка → один HTML с правильным MIME для Pages.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT="$(pwd)"
|
||||
SRC="$ROOT/executive-os"
|
||||
OUT="$ROOT/index.html"
|
||||
|
||||
[ -f "$SRC/index.html" ] || { echo "ERR: $SRC/index.html not found"; exit 1; }
|
||||
|
||||
/usr/bin/python3 <<PY
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
src = Path("$SRC")
|
||||
html = (src / "index.html").read_text()
|
||||
css = (src / "styles.css").read_text()
|
||||
data = (src / "data.js").read_text()
|
||||
app = (src / "app.js").read_text()
|
||||
icon = (src / "icon.svg").read_text()
|
||||
icon_uri = "data:image/svg+xml;base64," + base64.b64encode(icon.encode()).decode()
|
||||
|
||||
html = html.replace('<link rel="stylesheet" href="styles.css" />', f'<style>\n{css}\n</style>')
|
||||
html = html.replace('<script src="data.js"></script>', f'<script>\n{data}\n</script>')
|
||||
html = html.replace('<script src="app.js"></script>', f'<script>\n{app}\n</script>')
|
||||
html = html.replace('href="icon.svg"', f'href="{icon_uri}"')
|
||||
|
||||
Path("$OUT").write_text(html)
|
||||
print(f"built single-file index.html: {len(html):,} bytes")
|
||||
PY
|
||||
Reference in New Issue
Block a user