heyhank 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +2 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { readFileSync, existsSync, mkdirSync } from "fs";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { atomicWriteFileSync } from "../fs-utils.js";
|
|
5
|
+
|
|
6
|
+
export interface InvoiceItem {
|
|
7
|
+
description: string;
|
|
8
|
+
quantity: number;
|
|
9
|
+
unitPrice: number;
|
|
10
|
+
total: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Invoice {
|
|
14
|
+
id: string;
|
|
15
|
+
invoiceNumber: string;
|
|
16
|
+
clientName: string;
|
|
17
|
+
clientEmail?: string;
|
|
18
|
+
clientAddress?: string;
|
|
19
|
+
items: InvoiceItem[];
|
|
20
|
+
subtotal: number;
|
|
21
|
+
taxRate: number; // percentage
|
|
22
|
+
taxAmount: number;
|
|
23
|
+
total: number;
|
|
24
|
+
currency: string;
|
|
25
|
+
status: "draft" | "sent" | "paid" | "overdue" | "cancelled";
|
|
26
|
+
issueDate: string;
|
|
27
|
+
dueDate: string;
|
|
28
|
+
paidDate?: string;
|
|
29
|
+
notes?: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Expense {
|
|
35
|
+
id: string;
|
|
36
|
+
description: string;
|
|
37
|
+
amount: number;
|
|
38
|
+
currency: string;
|
|
39
|
+
category: string;
|
|
40
|
+
date: string;
|
|
41
|
+
project?: string;
|
|
42
|
+
vendor?: string;
|
|
43
|
+
receipt?: string; // file reference
|
|
44
|
+
recurring: boolean;
|
|
45
|
+
notes?: string;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FinancialSummary {
|
|
50
|
+
period: string;
|
|
51
|
+
startDate: string;
|
|
52
|
+
endDate: string;
|
|
53
|
+
totalRevenue: number;
|
|
54
|
+
totalExpenses: number;
|
|
55
|
+
netProfit: number;
|
|
56
|
+
currency: string;
|
|
57
|
+
invoicesByStatus: Record<string, { count: number; total: number }>;
|
|
58
|
+
expensesByCategory: Record<string, number>;
|
|
59
|
+
revenueByMonth: Record<string, number>;
|
|
60
|
+
expensesByMonth: Record<string, number>;
|
|
61
|
+
outstandingInvoices: Invoice[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DATA_DIR = join(process.env.HEYHANK_HOME || join(process.env.HOME || "/root", ".heyhank"), "finance");
|
|
65
|
+
const INVOICES_FILE = join(DATA_DIR, "invoices.json");
|
|
66
|
+
const EXPENSES_FILE = join(DATA_DIR, "expenses.json");
|
|
67
|
+
const SETTINGS_FILE = join(DATA_DIR, "settings.json");
|
|
68
|
+
|
|
69
|
+
function ensureDir() {
|
|
70
|
+
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadInvoices(): Invoice[] {
|
|
74
|
+
ensureDir();
|
|
75
|
+
if (!existsSync(INVOICES_FILE)) return [];
|
|
76
|
+
try { return JSON.parse(readFileSync(INVOICES_FILE, "utf-8")); }
|
|
77
|
+
catch { return []; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function saveInvoices(invoices: Invoice[]) {
|
|
81
|
+
ensureDir();
|
|
82
|
+
atomicWriteFileSync(INVOICES_FILE, JSON.stringify(invoices, null, 2));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function loadExpenses(): Expense[] {
|
|
86
|
+
ensureDir();
|
|
87
|
+
if (!existsSync(EXPENSES_FILE)) return [];
|
|
88
|
+
try { return JSON.parse(readFileSync(EXPENSES_FILE, "utf-8")); }
|
|
89
|
+
catch { return []; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function saveExpenses(expenses: Expense[]) {
|
|
93
|
+
ensureDir();
|
|
94
|
+
atomicWriteFileSync(EXPENSES_FILE, JSON.stringify(expenses, null, 2));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface FinanceSettings {
|
|
98
|
+
defaultCurrency: string;
|
|
99
|
+
defaultTaxRate: number;
|
|
100
|
+
invoicePrefix: string;
|
|
101
|
+
nextInvoiceNumber: number;
|
|
102
|
+
companyName?: string;
|
|
103
|
+
companyAddress?: string;
|
|
104
|
+
companyEmail?: string;
|
|
105
|
+
bankDetails?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function loadSettings(): FinanceSettings {
|
|
109
|
+
ensureDir();
|
|
110
|
+
if (!existsSync(SETTINGS_FILE)) {
|
|
111
|
+
return { defaultCurrency: "EUR", defaultTaxRate: 20, invoicePrefix: "INV", nextInvoiceNumber: 1 };
|
|
112
|
+
}
|
|
113
|
+
try { return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8")); }
|
|
114
|
+
catch { return { defaultCurrency: "EUR", defaultTaxRate: 20, invoicePrefix: "INV", nextInvoiceNumber: 1 }; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function saveSettings(settings: FinanceSettings) {
|
|
118
|
+
ensureDir();
|
|
119
|
+
atomicWriteFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function generateInvoiceNumber(): string {
|
|
123
|
+
const settings = loadSettings();
|
|
124
|
+
const num = `${settings.invoicePrefix}-${String(settings.nextInvoiceNumber).padStart(4, "0")}`;
|
|
125
|
+
settings.nextInvoiceNumber++;
|
|
126
|
+
saveSettings(settings);
|
|
127
|
+
return num;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Invoices
|
|
131
|
+
export function createInvoice(clientName: string, items: InvoiceItem[], opts?: { clientEmail?: string; clientAddress?: string; taxRate?: number; currency?: string; dueDate?: string; notes?: string }): Invoice {
|
|
132
|
+
const invoices = loadInvoices();
|
|
133
|
+
const settings = loadSettings();
|
|
134
|
+
const taxRate = opts?.taxRate ?? settings.defaultTaxRate;
|
|
135
|
+
const subtotal = items.reduce((sum, i) => sum + i.total, 0);
|
|
136
|
+
const taxAmount = Math.round(subtotal * taxRate / 100 * 100) / 100;
|
|
137
|
+
|
|
138
|
+
const invoice: Invoice = {
|
|
139
|
+
id: randomUUID(),
|
|
140
|
+
invoiceNumber: generateInvoiceNumber(),
|
|
141
|
+
clientName,
|
|
142
|
+
clientEmail: opts?.clientEmail,
|
|
143
|
+
clientAddress: opts?.clientAddress,
|
|
144
|
+
items,
|
|
145
|
+
subtotal,
|
|
146
|
+
taxRate,
|
|
147
|
+
taxAmount,
|
|
148
|
+
total: subtotal + taxAmount,
|
|
149
|
+
currency: opts?.currency || settings.defaultCurrency,
|
|
150
|
+
status: "draft",
|
|
151
|
+
issueDate: new Date().toISOString().slice(0, 10),
|
|
152
|
+
dueDate: opts?.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
|
|
153
|
+
notes: opts?.notes,
|
|
154
|
+
createdAt: new Date().toISOString(),
|
|
155
|
+
updatedAt: new Date().toISOString()
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
invoices.push(invoice);
|
|
159
|
+
saveInvoices(invoices);
|
|
160
|
+
return invoice;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function listInvoices(status?: string, startDate?: string, endDate?: string): Invoice[] {
|
|
164
|
+
let invoices = loadInvoices();
|
|
165
|
+
if (status) invoices = invoices.filter(i => i.status === status);
|
|
166
|
+
if (startDate) invoices = invoices.filter(i => i.issueDate >= startDate);
|
|
167
|
+
if (endDate) invoices = invoices.filter(i => i.issueDate <= endDate);
|
|
168
|
+
return invoices.sort((a, b) => b.issueDate.localeCompare(a.issueDate));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getInvoice(id: string): Invoice | null {
|
|
172
|
+
return loadInvoices().find(i => i.id === id) || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function updateInvoice(id: string, patch: Partial<Pick<Invoice, "status" | "clientName" | "clientEmail" | "clientAddress" | "items" | "notes" | "dueDate" | "paidDate">>): Invoice | null {
|
|
176
|
+
const invoices = loadInvoices();
|
|
177
|
+
const idx = invoices.findIndex(i => i.id === id);
|
|
178
|
+
if (idx === -1) return null;
|
|
179
|
+
|
|
180
|
+
if (patch.items) {
|
|
181
|
+
const subtotal = patch.items.reduce((sum, i) => sum + i.total, 0);
|
|
182
|
+
invoices[idx].items = patch.items;
|
|
183
|
+
invoices[idx].subtotal = subtotal;
|
|
184
|
+
invoices[idx].taxAmount = Math.round(subtotal * invoices[idx].taxRate / 100 * 100) / 100;
|
|
185
|
+
invoices[idx].total = subtotal + invoices[idx].taxAmount;
|
|
186
|
+
}
|
|
187
|
+
if (patch.status !== undefined) invoices[idx].status = patch.status;
|
|
188
|
+
if (patch.clientName !== undefined) invoices[idx].clientName = patch.clientName;
|
|
189
|
+
if (patch.clientEmail !== undefined) invoices[idx].clientEmail = patch.clientEmail;
|
|
190
|
+
if (patch.clientAddress !== undefined) invoices[idx].clientAddress = patch.clientAddress;
|
|
191
|
+
if (patch.notes !== undefined) invoices[idx].notes = patch.notes;
|
|
192
|
+
if (patch.dueDate !== undefined) invoices[idx].dueDate = patch.dueDate;
|
|
193
|
+
if (patch.paidDate !== undefined) invoices[idx].paidDate = patch.paidDate;
|
|
194
|
+
invoices[idx].updatedAt = new Date().toISOString();
|
|
195
|
+
|
|
196
|
+
saveInvoices(invoices);
|
|
197
|
+
return invoices[idx];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function markPaid(id: string): Invoice | null {
|
|
201
|
+
return updateInvoice(id, { status: "paid", paidDate: new Date().toISOString().slice(0, 10) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function deleteInvoice(id: string): boolean {
|
|
205
|
+
const invoices = loadInvoices();
|
|
206
|
+
const idx = invoices.findIndex(i => i.id === id);
|
|
207
|
+
if (idx === -1) return false;
|
|
208
|
+
invoices.splice(idx, 1);
|
|
209
|
+
saveInvoices(invoices);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Expenses
|
|
214
|
+
export function logExpense(description: string, amount: number, category: string, opts?: { currency?: string; date?: string; project?: string; vendor?: string; recurring?: boolean; notes?: string }): Expense {
|
|
215
|
+
const expenses = loadExpenses();
|
|
216
|
+
const settings = loadSettings();
|
|
217
|
+
const expense: Expense = {
|
|
218
|
+
id: randomUUID(),
|
|
219
|
+
description,
|
|
220
|
+
amount,
|
|
221
|
+
currency: opts?.currency || settings.defaultCurrency,
|
|
222
|
+
category,
|
|
223
|
+
date: opts?.date || new Date().toISOString().slice(0, 10),
|
|
224
|
+
project: opts?.project,
|
|
225
|
+
vendor: opts?.vendor,
|
|
226
|
+
recurring: opts?.recurring || false,
|
|
227
|
+
notes: opts?.notes,
|
|
228
|
+
createdAt: new Date().toISOString()
|
|
229
|
+
};
|
|
230
|
+
expenses.unshift(expense);
|
|
231
|
+
saveExpenses(expenses);
|
|
232
|
+
return expense;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function listExpenses(category?: string, startDate?: string, endDate?: string): Expense[] {
|
|
236
|
+
let expenses = loadExpenses();
|
|
237
|
+
if (category) expenses = expenses.filter(e => e.category === category);
|
|
238
|
+
if (startDate) expenses = expenses.filter(e => e.date >= startDate);
|
|
239
|
+
if (endDate) expenses = expenses.filter(e => e.date <= endDate);
|
|
240
|
+
return expenses.sort((a, b) => b.date.localeCompare(a.date));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function deleteExpense(id: string): boolean {
|
|
244
|
+
const expenses = loadExpenses();
|
|
245
|
+
const idx = expenses.findIndex(e => e.id === id);
|
|
246
|
+
if (idx === -1) return false;
|
|
247
|
+
expenses.splice(idx, 1);
|
|
248
|
+
saveExpenses(expenses);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Financial Summary
|
|
253
|
+
export function getFinancialSummary(period: "month" | "quarter" | "year" | "custom", startDate?: string, endDate?: string): FinancialSummary {
|
|
254
|
+
const now = new Date();
|
|
255
|
+
let start: Date;
|
|
256
|
+
let end: Date = now;
|
|
257
|
+
|
|
258
|
+
switch (period) {
|
|
259
|
+
case "month":
|
|
260
|
+
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
261
|
+
break;
|
|
262
|
+
case "quarter":
|
|
263
|
+
const qMonth = Math.floor(now.getMonth() / 3) * 3;
|
|
264
|
+
start = new Date(now.getFullYear(), qMonth, 1);
|
|
265
|
+
break;
|
|
266
|
+
case "year":
|
|
267
|
+
start = new Date(now.getFullYear(), 0, 1);
|
|
268
|
+
break;
|
|
269
|
+
case "custom":
|
|
270
|
+
start = startDate ? new Date(startDate) : new Date(now.getFullYear(), 0, 1);
|
|
271
|
+
end = endDate ? new Date(endDate) : now;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const startStr = start.toISOString().slice(0, 10);
|
|
276
|
+
const endStr = end.toISOString().slice(0, 10);
|
|
277
|
+
|
|
278
|
+
const invoices = listInvoices(undefined, startStr, endStr);
|
|
279
|
+
const expenses = listExpenses(undefined, startStr, endStr);
|
|
280
|
+
|
|
281
|
+
const invoicesByStatus: Record<string, { count: number; total: number }> = {};
|
|
282
|
+
let totalRevenue = 0;
|
|
283
|
+
const revenueByMonth: Record<string, number> = {};
|
|
284
|
+
const outstandingInvoices: Invoice[] = [];
|
|
285
|
+
|
|
286
|
+
for (const inv of invoices) {
|
|
287
|
+
if (!invoicesByStatus[inv.status]) invoicesByStatus[inv.status] = { count: 0, total: 0 };
|
|
288
|
+
invoicesByStatus[inv.status].count++;
|
|
289
|
+
invoicesByStatus[inv.status].total += inv.total;
|
|
290
|
+
|
|
291
|
+
if (inv.status === "paid") {
|
|
292
|
+
totalRevenue += inv.total;
|
|
293
|
+
const month = inv.issueDate.slice(0, 7);
|
|
294
|
+
revenueByMonth[month] = (revenueByMonth[month] || 0) + inv.total;
|
|
295
|
+
}
|
|
296
|
+
if (inv.status === "sent" || inv.status === "overdue") {
|
|
297
|
+
outstandingInvoices.push(inv);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const expensesByCategory: Record<string, number> = {};
|
|
302
|
+
const expensesByMonth: Record<string, number> = {};
|
|
303
|
+
let totalExpenses = 0;
|
|
304
|
+
|
|
305
|
+
for (const exp of expenses) {
|
|
306
|
+
totalExpenses += exp.amount;
|
|
307
|
+
expensesByCategory[exp.category] = (expensesByCategory[exp.category] || 0) + exp.amount;
|
|
308
|
+
const month = exp.date.slice(0, 7);
|
|
309
|
+
expensesByMonth[month] = (expensesByMonth[month] || 0) + exp.amount;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const settings = loadSettings();
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
period,
|
|
316
|
+
startDate: startStr,
|
|
317
|
+
endDate: endStr,
|
|
318
|
+
totalRevenue,
|
|
319
|
+
totalExpenses,
|
|
320
|
+
netProfit: totalRevenue - totalExpenses,
|
|
321
|
+
currency: settings.defaultCurrency,
|
|
322
|
+
invoicesByStatus,
|
|
323
|
+
expensesByCategory,
|
|
324
|
+
revenueByMonth,
|
|
325
|
+
expensesByMonth,
|
|
326
|
+
outstandingInvoices
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function getFinanceSettings(): FinanceSettings {
|
|
331
|
+
return loadSettings();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function updateFinanceSettings(patch: Partial<FinanceSettings>): FinanceSettings {
|
|
335
|
+
const settings = loadSettings();
|
|
336
|
+
Object.assign(settings, patch);
|
|
337
|
+
saveSettings(settings);
|
|
338
|
+
return settings;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function listExpenseCategories(): string[] {
|
|
342
|
+
return [...new Set(loadExpenses().map(e => e.category))].sort();
|
|
343
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { readFileSync, existsSync, mkdirSync } from "fs";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { atomicWriteFileSync } from "../fs-utils.js";
|
|
5
|
+
|
|
6
|
+
export interface KPIValue {
|
|
7
|
+
value: number;
|
|
8
|
+
date: string;
|
|
9
|
+
note?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface KPI {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
unit: string; // %, EUR, count, hours, etc.
|
|
17
|
+
category: string; // revenue, growth, operations, marketing, custom
|
|
18
|
+
target?: number;
|
|
19
|
+
warningThreshold?: number; // below this = yellow
|
|
20
|
+
criticalThreshold?: number; // below this = red
|
|
21
|
+
direction: "up" | "down"; // up = higher is better, down = lower is better
|
|
22
|
+
currentValue?: number;
|
|
23
|
+
previousValue?: number;
|
|
24
|
+
trend?: "up" | "down" | "stable";
|
|
25
|
+
trendPercent?: number;
|
|
26
|
+
history: KPIValue[];
|
|
27
|
+
createdAt: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface KPIDashboard {
|
|
32
|
+
kpis: KPI[];
|
|
33
|
+
lastUpdated: string;
|
|
34
|
+
summary: {
|
|
35
|
+
total: number;
|
|
36
|
+
onTarget: number;
|
|
37
|
+
warning: number;
|
|
38
|
+
critical: number;
|
|
39
|
+
noData: number;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DATA_DIR = join(process.env.HEYHANK_HOME || join(process.env.HOME || "/root", ".heyhank"), "kpi");
|
|
44
|
+
const KPI_FILE = join(DATA_DIR, "kpis.json");
|
|
45
|
+
|
|
46
|
+
function ensureDir() {
|
|
47
|
+
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function load(): KPI[] {
|
|
51
|
+
ensureDir();
|
|
52
|
+
if (!existsSync(KPI_FILE)) return [];
|
|
53
|
+
try { return JSON.parse(readFileSync(KPI_FILE, "utf-8")); }
|
|
54
|
+
catch { return []; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function save(kpis: KPI[]) {
|
|
58
|
+
ensureDir();
|
|
59
|
+
atomicWriteFileSync(KPI_FILE, JSON.stringify(kpis, null, 2));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function calculateTrend(history: KPIValue[]): { trend: "up" | "down" | "stable"; percent: number } {
|
|
63
|
+
if (history.length < 2) return { trend: "stable", percent: 0 };
|
|
64
|
+
const sorted = [...history].sort((a, b) => b.date.localeCompare(a.date));
|
|
65
|
+
const current = sorted[0].value;
|
|
66
|
+
const previous = sorted[1].value;
|
|
67
|
+
if (previous === 0) return { trend: current > 0 ? "up" : "stable", percent: 0 };
|
|
68
|
+
const percent = Math.round(((current - previous) / Math.abs(previous)) * 100 * 10) / 10;
|
|
69
|
+
return {
|
|
70
|
+
trend: percent > 1 ? "up" : percent < -1 ? "down" : "stable",
|
|
71
|
+
percent
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getStatus(kpi: KPI): "on-target" | "warning" | "critical" | "no-data" {
|
|
76
|
+
if (kpi.currentValue === undefined) return "no-data";
|
|
77
|
+
if (kpi.target === undefined) return "on-target";
|
|
78
|
+
|
|
79
|
+
const ratio = kpi.direction === "up"
|
|
80
|
+
? kpi.currentValue / kpi.target
|
|
81
|
+
: kpi.target / kpi.currentValue;
|
|
82
|
+
|
|
83
|
+
if (kpi.criticalThreshold !== undefined && ratio < kpi.criticalThreshold / 100) return "critical";
|
|
84
|
+
if (kpi.warningThreshold !== undefined && ratio < kpi.warningThreshold / 100) return "warning";
|
|
85
|
+
return ratio >= 1 ? "on-target" : "warning";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function defineKPI(name: string, unit: string, category: string, opts?: { description?: string; target?: number; direction?: "up" | "down"; warningThreshold?: number; criticalThreshold?: number }): KPI {
|
|
89
|
+
const kpis = load();
|
|
90
|
+
const kpi: KPI = {
|
|
91
|
+
id: randomUUID(),
|
|
92
|
+
name,
|
|
93
|
+
description: opts?.description,
|
|
94
|
+
unit,
|
|
95
|
+
category,
|
|
96
|
+
target: opts?.target,
|
|
97
|
+
warningThreshold: opts?.warningThreshold || 80,
|
|
98
|
+
criticalThreshold: opts?.criticalThreshold || 50,
|
|
99
|
+
direction: opts?.direction || "up",
|
|
100
|
+
history: [],
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
updatedAt: new Date().toISOString()
|
|
103
|
+
};
|
|
104
|
+
kpis.push(kpi);
|
|
105
|
+
save(kpis);
|
|
106
|
+
return kpi;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function recordValue(kpiId: string, value: number, date?: string, note?: string): KPI | null {
|
|
110
|
+
const kpis = load();
|
|
111
|
+
const idx = kpis.findIndex(k => k.id === kpiId);
|
|
112
|
+
if (idx === -1) return null;
|
|
113
|
+
|
|
114
|
+
const entry: KPIValue = {
|
|
115
|
+
value,
|
|
116
|
+
date: date || new Date().toISOString().slice(0, 10),
|
|
117
|
+
note
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
kpis[idx].history.push(entry);
|
|
121
|
+
kpis[idx].history.sort((a, b) => b.date.localeCompare(a.date));
|
|
122
|
+
|
|
123
|
+
// Keep max 365 entries
|
|
124
|
+
if (kpis[idx].history.length > 365) kpis[idx].history = kpis[idx].history.slice(0, 365);
|
|
125
|
+
|
|
126
|
+
// Update current/previous
|
|
127
|
+
kpis[idx].previousValue = kpis[idx].currentValue;
|
|
128
|
+
kpis[idx].currentValue = value;
|
|
129
|
+
|
|
130
|
+
// Calculate trend
|
|
131
|
+
const { trend, percent } = calculateTrend(kpis[idx].history);
|
|
132
|
+
kpis[idx].trend = trend;
|
|
133
|
+
kpis[idx].trendPercent = percent;
|
|
134
|
+
kpis[idx].updatedAt = new Date().toISOString();
|
|
135
|
+
|
|
136
|
+
save(kpis);
|
|
137
|
+
return kpis[idx];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getKPI(id: string): KPI | null {
|
|
141
|
+
return load().find(k => k.id === id) || null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function listKPIs(category?: string): KPI[] {
|
|
145
|
+
let kpis = load();
|
|
146
|
+
if (category) kpis = kpis.filter(k => k.category === category);
|
|
147
|
+
return kpis;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function updateKPI(id: string, patch: Partial<Pick<KPI, "name" | "description" | "unit" | "category" | "target" | "direction" | "warningThreshold" | "criticalThreshold">>): KPI | null {
|
|
151
|
+
const kpis = load();
|
|
152
|
+
const idx = kpis.findIndex(k => k.id === id);
|
|
153
|
+
if (idx === -1) return null;
|
|
154
|
+
Object.assign(kpis[idx], patch);
|
|
155
|
+
kpis[idx].updatedAt = new Date().toISOString();
|
|
156
|
+
save(kpis);
|
|
157
|
+
return kpis[idx];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function deleteKPI(id: string): boolean {
|
|
161
|
+
const kpis = load();
|
|
162
|
+
const idx = kpis.findIndex(k => k.id === id);
|
|
163
|
+
if (idx === -1) return false;
|
|
164
|
+
kpis.splice(idx, 1);
|
|
165
|
+
save(kpis);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getDashboard(): KPIDashboard {
|
|
170
|
+
const kpis = load();
|
|
171
|
+
let onTarget = 0, warning = 0, critical = 0, noData = 0;
|
|
172
|
+
|
|
173
|
+
for (const kpi of kpis) {
|
|
174
|
+
const status = getStatus(kpi);
|
|
175
|
+
if (status === "on-target") onTarget++;
|
|
176
|
+
else if (status === "warning") warning++;
|
|
177
|
+
else if (status === "critical") critical++;
|
|
178
|
+
else noData++;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
kpis,
|
|
183
|
+
lastUpdated: new Date().toISOString(),
|
|
184
|
+
summary: { total: kpis.length, onTarget, warning, critical, noData }
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getKPIHistory(id: string, period?: "week" | "month" | "quarter" | "year"): KPIValue[] {
|
|
189
|
+
const kpi = getKPI(id);
|
|
190
|
+
if (!kpi) return [];
|
|
191
|
+
|
|
192
|
+
if (!period) return kpi.history;
|
|
193
|
+
|
|
194
|
+
const now = new Date();
|
|
195
|
+
let cutoff: Date;
|
|
196
|
+
switch (period) {
|
|
197
|
+
case "week": cutoff = new Date(now.getTime() - 7 * 86400000); break;
|
|
198
|
+
case "month": cutoff = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); break;
|
|
199
|
+
case "quarter": cutoff = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate()); break;
|
|
200
|
+
case "year": cutoff = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); break;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return kpi.history.filter(v => v.date >= cutoff.toISOString().slice(0, 10));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function listCategories(): string[] {
|
|
207
|
+
return [...new Set(load().map(k => k.category))].sort();
|
|
208
|
+
}
|