Compare commits

...

No commits in common. "main" and "gitea-pages" have entirely different histories.

66 changed files with 0 additions and 7755 deletions

33
.gitignore vendored
View File

@ -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/

View File

@ -1 +0,0 @@
/Users/telecom/Dev/_hack22may/GITEA.md

465
PRD.md
View File

@ -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.

View File

@ -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
View File

@ -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
View File

@ -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.

View File

@ -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);
})();

View File

@ -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
}
};

View File

@ -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>

View File

@ -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); }

View File

@ -1,3 +0,0 @@
DATABASE_URL=sqlite:///./atlas.db
CORS_ORIGINS=["http://localhost:3000"]
DEFAULT_USER_EMAIL=demo@atlas.local

7
backend/.gitignore vendored
View File

@ -1,7 +0,0 @@
.venv/
__pycache__/
*.pyc
*.db
.env
.env.local
sample_broker_statement.xlsx

View File

@ -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,
}

View File

@ -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"

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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"}

View File

@ -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}

View File

@ -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())

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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"]),
},
)

View File

@ -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],
)

View File

@ -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"],
)

View File

@ -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]

View File

@ -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

View File

@ -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]

View File

@ -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()
)

View File

@ -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

View File

@ -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()

View File

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
// ---------- 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);
})();

View File

@ -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 дня."
};

View File

@ -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

View File

@ -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>

View File

@ -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"
}
]
}

View File

@ -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); }
}

View File

@ -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))
);
});

View File

@ -1 +0,0 @@
NEXT_PUBLIC_API_BASE=http://localhost:8000

5
frontend/.gitignore vendored
View File

@ -1,5 +0,0 @@
node_modules
.next
out
.env*.local
*.log

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 });
}

View File

@ -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 }[];
}

View File

@ -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.

View File

@ -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*` }];
}
};

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -1,3 +0,0 @@
module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} }
};

View File

@ -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: []
};

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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