Add savings chat analysis feature
This commit is contained in:
@@ -14,18 +14,22 @@ npm run dev:all
|
|||||||
|
|
||||||
Erstlogin: Registrieren auf `/login` → `ensureSeeded` legt Standard-Kategorien und Einstellungen an.
|
Erstlogin: Registrieren auf `/login` → `ensureSeeded` legt Standard-Kategorien und Einstellungen an.
|
||||||
|
|
||||||
## Convex Secrets (comdirect)
|
## Convex Secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx convex env set COMDIRECT_CLIENT_ID "…"
|
npx convex env set COMDIRECT_CLIENT_ID "…"
|
||||||
npx convex env set COMDIRECT_CLIENT_SECRET "…"
|
npx convex env set COMDIRECT_CLIENT_SECRET "…"
|
||||||
|
npx convex env set OPENAI_API_KEY "…"
|
||||||
|
npx convex env set SAVINGS_CHAT_MODEL "gpt-5.4-mini" # optional
|
||||||
```
|
```
|
||||||
|
|
||||||
Zugangsnummer und PIN werden **nur** pro Sync-Session eingegeben und nie gespeichert.
|
Zugangsnummer und PIN werden **nur** pro Sync-Session eingegeben und nie gespeichert.
|
||||||
|
`SAVINGS_CHAT_MODEL` ist optional; bei Fehlen wird `gpt-5.4-mini` → `gpt-4.1-mini` → `gpt-4.1` als Fallback genutzt.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- Dashboard mit KPIs, Charts, Monats-Basis (effective/booking)
|
- Dashboard mit KPIs, Charts, Monats-Basis (effective/booking)
|
||||||
|
- KI-Analyse: neuer Bereich „Talk to Savings“ unter `/talk`
|
||||||
- Transaktionen (paginiert, Filter, Bulk-Kategorisierung, Monatszuordnung)
|
- Transaktionen (paginiert, Filter, Bulk-Kategorisierung, Monatszuordnung)
|
||||||
- Kategorien-CRUD mit Seed-Kategorien (§5 Spezifikation)
|
- Kategorien-CRUD mit Seed-Kategorien (§5 Spezifikation)
|
||||||
- Kredite inkl. Tilgungsplan (keine Auto-Buchung als Transaktionen)
|
- Kredite inkl. Tilgungsplan (keine Auto-Buchung als Transaktionen)
|
||||||
|
|||||||
15
backlog/config.yml
Normal file
15
backlog/config.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
project_name: "finanz-dashboard"
|
||||||
|
default_status: "To Do"
|
||||||
|
statuses: ["To Do", "In Progress", "Done"]
|
||||||
|
labels: []
|
||||||
|
date_format: yyyy-mm-dd
|
||||||
|
max_column_width: 20
|
||||||
|
auto_open_browser: true
|
||||||
|
default_port: 6420
|
||||||
|
remote_operations: true
|
||||||
|
auto_commit: false
|
||||||
|
filesystem_only: false
|
||||||
|
bypass_git_hooks: false
|
||||||
|
check_active_branches: true
|
||||||
|
active_branch_days: 30
|
||||||
|
task_prefix: "task"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
id: TASK-1
|
||||||
|
title: Add complete chat transaction context
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 13:52'
|
||||||
|
updated_date: '2026-06-15 14:02'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 1000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Make the savings chat evaluate every transaction matching the selected date range, account, and basis instead of silently sampling 150/300 rows. This includes exact context totals, a full prompt context for the AI action, and regression coverage for large transaction windows.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Chat context totals count all matching transactions in the selected range, including more than 400 rows
|
||||||
|
- [x] #2 Account filtering is applied before counting and summing transactions
|
||||||
|
- [x] #3 The chat action no longer accepts or passes maxTransactions and reports the complete transaction count
|
||||||
|
- [x] #4 The frontend no longer passes maxTransactions and continues to show exact totals
|
||||||
|
- [x] #5 Regression tests, lint, and build verification pass or documented blockers are reported
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add Convex test dependencies and Vitest edge-runtime config
|
||||||
|
2. Write failing convex-test regression for 450 chat transactions and account filtering
|
||||||
|
3. Refactor savingsChat backend to remove maxTransactions and use indexed full scans
|
||||||
|
4. Update schema indexes for account+basis filters
|
||||||
|
5. Remove frontend maxTransactions arguments
|
||||||
|
6. Run focused test, lint, and build verification
|
||||||
|
7. Record verification notes and leave task In Progress pending user confirmation
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
RED verified: npx vitest convex/savingsChat.test.ts --run fails because getContext does not return isComplete and still uses limited context shape.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- PASS npx vitest convex/savingsChat.test.ts --run
|
||||||
|
- PASS npm run build
|
||||||
|
- PASS npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx vitest.config.ts
|
||||||
|
- BLOCKED npm run lint still fails on pre-existing/unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/UI fast-refresh exports, SettingsPage.tsx, and related existing warnings. SavingsChatPage is no longer in the lint output.
|
||||||
|
|
||||||
|
Review fix:
|
||||||
|
- Added effective-basis fallback for legacy transactions without effectiveMonth by reading bookingDate range and including only rows where effectiveMonth is undefined.
|
||||||
|
- Added prompt-context regression coverage via internal.savingsChat.getPromptContext.
|
||||||
|
- PASS npx vitest convex/savingsChat.test.ts --run (2 tests)
|
||||||
|
- PASS npm run build
|
||||||
|
- PASS npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx vitest.config.ts
|
||||||
|
|
||||||
|
Final review:
|
||||||
|
- Subagent re-review found no remaining correctness/spec issues.
|
||||||
|
- Prior blocker resolved: effective-basis loading now includes rows without effectiveMonth via bookingDate fallback queries.
|
||||||
|
- Fresh full npm run lint still fails only on unrelated existing files; no SavingsChatPage issue remains.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
id: TASK-2
|
||||||
|
title: Render category expense chart values as positive slices
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 14:34'
|
||||||
|
updated_date: '2026-06-15 14:48'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 2000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Fix the dashboard category expense pie chart so negative expense totals render as visible pie slices while preserving expense-style formatting in the UI.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Category breakdown pie renders visible slices for negative expense totals
|
||||||
|
- [x] #2 Tooltip still displays expense values with the existing currency formatting
|
||||||
|
- [x] #3 Build or focused verification passes, or blockers are documented
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Normalize category breakdown values for chart rendering
|
||||||
|
2. Keep tooltip currency formatting consistent with expense values
|
||||||
|
3. Run focused build/type verification
|
||||||
|
4. Record verification notes and leave task In Progress pending user confirmation
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
RED verified: npx vitest src/components/charts/CategoryBreakdownChart.test.ts --run fails because toCategoryPieData is missing.
|
||||||
|
|
||||||
|
Implemented category pie normalization via src/components/charts/categoryBreakdownData.ts and wired CategoryBreakdownChart to use chartAmount for Recharts while formatting tooltip values from the original signed amount.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- PASS npx vitest src/components/charts/CategoryBreakdownChart.test.ts --run
|
||||||
|
- PASS npx eslint src/components/charts/CategoryBreakdownChart.tsx src/components/charts/CategoryBreakdownChart.test.ts src/components/charts/categoryBreakdownData.ts vitest.config.ts
|
||||||
|
- PASS npm run build
|
||||||
|
|
||||||
|
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
|
||||||
|
|
||||||
|
Manual browser verification not completed in-agent because the in-app Browser target iab is unavailable in this session. Vite dev server is running at http://127.0.0.1:5173/ for user verification.
|
||||||
|
|
||||||
|
User requested readability improvement: place the pie chart on the left and category list on the right instead of using the crowded default legend below the chart.
|
||||||
|
|
||||||
|
Layout update: replaced the crowded Recharts category legend and slice labels with a responsive two-column layout: pie chart on the left, scrollable category list with swatches, signed amounts, and percentage shares on the right. Added stable min dimensions for the chart container after Vite logged Recharts container-size warnings during HMR.
|
||||||
|
|
||||||
|
Verification after layout update:
|
||||||
|
- PASS npx vitest src/components/charts/CategoryBreakdownChart.test.ts --run
|
||||||
|
- PASS npx eslint src/components/charts/CategoryBreakdownChart.tsx src/components/charts/CategoryBreakdownChart.test.ts src/components/charts/categoryBreakdownData.ts vitest.config.ts
|
||||||
|
- PASS npm run build
|
||||||
|
|
||||||
|
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -34,6 +34,7 @@ import type * as lib_helpers from "../lib/helpers.js";
|
|||||||
import type * as lib_month from "../lib/month.js";
|
import type * as lib_month from "../lib/month.js";
|
||||||
import type * as lib_seedCategories from "../lib/seedCategories.js";
|
import type * as lib_seedCategories from "../lib/seedCategories.js";
|
||||||
import type * as loans from "../loans.js";
|
import type * as loans from "../loans.js";
|
||||||
|
import type * as savingsChat from "../savingsChat.js";
|
||||||
import type * as settings from "../settings.js";
|
import type * as settings from "../settings.js";
|
||||||
import type * as transactions from "../transactions.js";
|
import type * as transactions from "../transactions.js";
|
||||||
import type * as users from "../users.js";
|
import type * as users from "../users.js";
|
||||||
@@ -71,6 +72,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
"lib/month": typeof lib_month;
|
"lib/month": typeof lib_month;
|
||||||
"lib/seedCategories": typeof lib_seedCategories;
|
"lib/seedCategories": typeof lib_seedCategories;
|
||||||
loans: typeof loans;
|
loans: typeof loans;
|
||||||
|
savingsChat: typeof savingsChat;
|
||||||
settings: typeof settings;
|
settings: typeof settings;
|
||||||
transactions: typeof transactions;
|
transactions: typeof transactions;
|
||||||
users: typeof users;
|
users: typeof users;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||||
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
|
||||||
import type { Id } from "../_generated/dataModel";
|
import type { Id } from "../_generated/dataModel";
|
||||||
import { categorize, roundEur } from "./categorize";
|
import { categorize, roundEur } from "./categorize";
|
||||||
import { computeEffectiveMonth, resolveAssignedAndEffective } from "./month";
|
import { computeEffectiveMonth, resolveAssignedAndEffective } from "./month";
|
||||||
import { computeDedupHash } from "./comdirectMap";
|
import { computeDedupHash } from "./comdirectMap";
|
||||||
|
|
||||||
export async function requireUserId(ctx: QueryCtx | MutationCtx): Promise<Id<"users">> {
|
export async function requireUserId(ctx: QueryCtx | MutationCtx | ActionCtx): Promise<Id<"users">> {
|
||||||
const userId = await getAuthUserId(ctx);
|
const userId = await getAuthUserId(ctx);
|
||||||
if (!userId) throw new Error("Nicht angemeldet");
|
if (!userId) throw new Error("Nicht angemeldet");
|
||||||
return userId;
|
return userId;
|
||||||
|
|||||||
194
convex/savingsChat.test.ts
Normal file
194
convex/savingsChat.test.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import { convexTest } from "convex-test";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { api, internal } from "./_generated/api";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
import schema from "./schema";
|
||||||
|
|
||||||
|
const modules = import.meta.glob("./**/*.ts");
|
||||||
|
delete modules["./savingsChat.test.ts"];
|
||||||
|
|
||||||
|
describe("savingsChat.getContext", () => {
|
||||||
|
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
|
||||||
|
const seeded = await t.run(async (ctx) => {
|
||||||
|
const userId = await ctx.db.insert("users", {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
});
|
||||||
|
const giroAccountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Girokonto",
|
||||||
|
type: "checking",
|
||||||
|
openingBalance: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
const otherAccountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Tagesgeld",
|
||||||
|
type: "savings",
|
||||||
|
openingBalance: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const amounts: number[] = [];
|
||||||
|
const months = ["2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"];
|
||||||
|
for (let index = 0; index < 450; index++) {
|
||||||
|
const month = months[index % months.length];
|
||||||
|
const day = String((index % 27) + 1).padStart(2, "0");
|
||||||
|
const bookingDate = `${month}-${day}`;
|
||||||
|
const amount = index % 3 === 0 ? 100 : -25;
|
||||||
|
amounts.push(amount);
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: giroAccountId,
|
||||||
|
bookingDate,
|
||||||
|
valueDate: bookingDate,
|
||||||
|
description: `Giro transaction ${index}`,
|
||||||
|
counterparty: "Counterparty",
|
||||||
|
amount,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: index % 10 === 0 ? undefined : bookingDate.slice(0, 7),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < 50; index++) {
|
||||||
|
const bookingDate = `2026-06-${String((index % 27) + 1).padStart(2, "0")}`;
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: otherAccountId,
|
||||||
|
bookingDate,
|
||||||
|
valueDate: bookingDate,
|
||||||
|
description: `Other account transaction ${index}`,
|
||||||
|
amount: 999,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: bookingDate.slice(0, 7),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
giroAccountId,
|
||||||
|
expectedIncome: amounts.filter((amount) => amount > 0).reduce((sum, amount) => sum + amount, 0),
|
||||||
|
expectedExpenses: amounts.filter((amount) => amount < 0).reduce((sum, amount) => sum + amount, 0),
|
||||||
|
expectedBalance: amounts.reduce((sum, amount) => sum + amount, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const asUser = t.withIdentity({
|
||||||
|
subject: `${seeded.userId}|test-session`,
|
||||||
|
tokenIdentifier: `test:${seeded.userId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await asUser.query(api.savingsChat.getContext, {
|
||||||
|
from: "2025-12-01",
|
||||||
|
to: "2026-06-30",
|
||||||
|
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||||
|
basis: "effective",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.accountName).toBe("Girokonto");
|
||||||
|
expect(context.isComplete).toBe(true);
|
||||||
|
expect(context.totals.transactionCount).toBe(450);
|
||||||
|
expect(context.totals.income).toBe(seeded.expectedIncome);
|
||||||
|
expect(context.totals.expenses).toBe(seeded.expectedExpenses);
|
||||||
|
expect(context.totals.balance).toBe(seeded.expectedBalance);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds complete prompt lines for every matching transaction", async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
|
||||||
|
const seeded = await t.run(async (ctx) => {
|
||||||
|
const userId = await ctx.db.insert("users", {
|
||||||
|
name: "Prompt User",
|
||||||
|
email: "prompt@example.com",
|
||||||
|
});
|
||||||
|
const categoryId = await ctx.db.insert("categories", {
|
||||||
|
userId,
|
||||||
|
name: "Lebensmittel",
|
||||||
|
kind: "ausgabe",
|
||||||
|
block: "variabel",
|
||||||
|
color: "#22c55e",
|
||||||
|
sortOrder: 1,
|
||||||
|
isSystem: false,
|
||||||
|
});
|
||||||
|
const giroAccountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Girokonto",
|
||||||
|
type: "checking",
|
||||||
|
openingBalance: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
const otherAccountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Depot",
|
||||||
|
type: "investment",
|
||||||
|
openingBalance: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: giroAccountId,
|
||||||
|
categoryId,
|
||||||
|
bookingDate: "2026-02-14",
|
||||||
|
valueDate: "2026-02-14",
|
||||||
|
description: "Supermarkt",
|
||||||
|
counterparty: "Markt GmbH",
|
||||||
|
amount: -42.5,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: giroAccountId,
|
||||||
|
bookingDate: "2026-02-15",
|
||||||
|
valueDate: "2026-02-15",
|
||||||
|
description: "Gehalt",
|
||||||
|
counterparty: "Arbeitgeber",
|
||||||
|
amount: 2500,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: "2026-02",
|
||||||
|
});
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: otherAccountId,
|
||||||
|
bookingDate: "2026-02-16",
|
||||||
|
valueDate: "2026-02-16",
|
||||||
|
description: "Other account should not appear",
|
||||||
|
amount: 999,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: "2026-02",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userId, giroAccountId };
|
||||||
|
});
|
||||||
|
|
||||||
|
const asUser = t.withIdentity({
|
||||||
|
subject: `${seeded.userId}|test-session`,
|
||||||
|
tokenIdentifier: `test:${seeded.userId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await asUser.query(internal.savingsChat.getPromptContext, {
|
||||||
|
from: "2026-02-01",
|
||||||
|
to: "2026-02-28",
|
||||||
|
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||||
|
basis: "effective",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.totals.transactionCount).toBe(2);
|
||||||
|
expect(context.transactionLines).toHaveLength(2);
|
||||||
|
expect(context.transactionLines.join("\n")).toContain(
|
||||||
|
"2026-02-14 | Supermarkt (Markt GmbH) | -42.50€ | Lebensmittel | Girokonto",
|
||||||
|
);
|
||||||
|
expect(context.transactionLines.join("\n")).toContain(
|
||||||
|
"2026-02-15 | Gehalt (Arbeitgeber) | 2500.00€ | Ohne Kategorie | Girokonto",
|
||||||
|
);
|
||||||
|
expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear");
|
||||||
|
});
|
||||||
|
});
|
||||||
399
convex/savingsChat.ts
Normal file
399
convex/savingsChat.ts
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import { action, internalQuery, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { generateText } from "ai";
|
||||||
|
import { openai } from "@ai-sdk/openai";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
|
import { bookingMonth } from "./lib/month";
|
||||||
|
import { requireUserId } from "./lib/helpers";
|
||||||
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
import type { QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
|
type ChatRole = "user" | "assistant";
|
||||||
|
type ChatMessage = { role: ChatRole; content: string };
|
||||||
|
|
||||||
|
const chatMessageValidator = v.object({
|
||||||
|
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||||
|
content: v.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_CONVERSATION_MESSAGES = 20;
|
||||||
|
const MAX_PROMPT_CHARACTERS = 180_000;
|
||||||
|
|
||||||
|
type ChatContextArgs = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
accountId?: Id<"accounts">;
|
||||||
|
basis: "effective" | "booking";
|
||||||
|
};
|
||||||
|
type ChatContextSummary = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
basis: "effective" | "booking";
|
||||||
|
accountId?: Id<"accounts">;
|
||||||
|
accountName?: string;
|
||||||
|
totals: { transactionCount: number; income: number; expenses: number; balance: number };
|
||||||
|
isComplete: true;
|
||||||
|
};
|
||||||
|
type ChatPromptContext = ChatContextSummary & {
|
||||||
|
transactionLines: string[];
|
||||||
|
};
|
||||||
|
type ChatAskResult = {
|
||||||
|
model: string;
|
||||||
|
answer: string;
|
||||||
|
usedTransactions: number;
|
||||||
|
usedBalance: { income: number; expenses: number; balance: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatEuro(value: number): string {
|
||||||
|
return `${value.toFixed(2)}€`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
|
||||||
|
return [
|
||||||
|
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
|
||||||
|
"Nutze ausschließlich die gelieferten Umsätze als Kontext und beziehe dich nur auf die angegebenen Werte.",
|
||||||
|
"Antworte auf Deutsch, kurz und handlungsorientiert.",
|
||||||
|
`Zeitraum: ${context.from} bis ${context.to}.`,
|
||||||
|
`Basis: ${context.basis}.`,
|
||||||
|
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
|
||||||
|
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
|
||||||
|
"Verwende keine Links, keine HTML-Tags und keine Emojis.",
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(context: ChatPromptContext, conversation: ChatMessage[]) {
|
||||||
|
return [
|
||||||
|
"Kontext der Auswertung:",
|
||||||
|
`Zeitraum: ${context.from} bis ${context.to}`,
|
||||||
|
`Basis: ${context.basis}`,
|
||||||
|
`Konto: ${context.accountName ?? "Alle Konten"}`,
|
||||||
|
`Anzahl Umsätze: ${context.totals.transactionCount}`,
|
||||||
|
`Einnahmen: ${formatEuro(context.totals.income)}`,
|
||||||
|
`Ausgaben: ${formatEuro(context.totals.expenses)}`,
|
||||||
|
`Saldo: ${formatEuro(context.totals.balance)}`,
|
||||||
|
"",
|
||||||
|
"Umsätze (neueste zuerst):",
|
||||||
|
...(context.transactionLines.length > 0
|
||||||
|
? context.transactionLines
|
||||||
|
: ["Keine Umsätze im Zeitraum."]),
|
||||||
|
"",
|
||||||
|
"Gesprächsverlauf:",
|
||||||
|
...conversation.map((message) => `${message.role}: ${message.content}`),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRole(role: ChatRole): "user" | "assistant" {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTransactionsForContext(
|
||||||
|
transactions: Doc<"transactions">[],
|
||||||
|
basis: ChatContextArgs["basis"],
|
||||||
|
) {
|
||||||
|
return transactions.sort((a, b) => {
|
||||||
|
const aMonth = basis === "effective" ? a.effectiveMonth ?? bookingMonth(a.bookingDate) ?? "" : "";
|
||||||
|
const bMonth = basis === "effective" ? b.effectiveMonth ?? bookingMonth(b.bookingDate) ?? "" : "";
|
||||||
|
const aDate = basis === "booking" ? a.bookingDate ?? "" : a.valueDate ?? a.bookingDate ?? "";
|
||||||
|
const bDate = basis === "booking" ? b.bookingDate ?? "" : b.valueDate ?? b.bookingDate ?? "";
|
||||||
|
const aKey = `${aMonth}|${aDate}|${a._creationTime}`;
|
||||||
|
const bKey = `${bMonth}|${bDate}|${b._creationTime}`;
|
||||||
|
return bKey.localeCompare(aKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMatchingTransactions(
|
||||||
|
ctx: QueryCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
args: ChatContextArgs,
|
||||||
|
): Promise<Doc<"transactions">[]> {
|
||||||
|
const monthFrom = args.from.slice(0, 7);
|
||||||
|
const monthTo = args.to.slice(0, 7);
|
||||||
|
const transactions: Doc<"transactions">[] = [];
|
||||||
|
|
||||||
|
if (args.basis === "effective") {
|
||||||
|
if (args.accountId) {
|
||||||
|
const accountId = args.accountId;
|
||||||
|
const q = ctx.db
|
||||||
|
.query("transactions")
|
||||||
|
.withIndex("by_user_account_effmonth", (index) =>
|
||||||
|
index
|
||||||
|
.eq("userId", userId)
|
||||||
|
.eq("accountId", accountId)
|
||||||
|
.gte("effectiveMonth", monthFrom)
|
||||||
|
.lte("effectiveMonth", monthTo),
|
||||||
|
)
|
||||||
|
.order("desc");
|
||||||
|
for await (const tx of q) transactions.push(tx);
|
||||||
|
|
||||||
|
const fallback = ctx.db
|
||||||
|
.query("transactions")
|
||||||
|
.withIndex("by_user_account_booking", (index) =>
|
||||||
|
index
|
||||||
|
.eq("userId", userId)
|
||||||
|
.eq("accountId", accountId)
|
||||||
|
.gte("bookingDate", args.from)
|
||||||
|
.lte("bookingDate", args.to),
|
||||||
|
)
|
||||||
|
.order("desc");
|
||||||
|
for await (const tx of fallback) {
|
||||||
|
if (tx.effectiveMonth === undefined) transactions.push(tx);
|
||||||
|
}
|
||||||
|
return sortTransactionsForContext(transactions, args.basis);
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = ctx.db
|
||||||
|
.query("transactions")
|
||||||
|
.withIndex("by_user_effmonth", (index) =>
|
||||||
|
index.eq("userId", userId).gte("effectiveMonth", monthFrom).lte("effectiveMonth", monthTo),
|
||||||
|
)
|
||||||
|
.order("desc");
|
||||||
|
for await (const tx of q) transactions.push(tx);
|
||||||
|
|
||||||
|
const fallback = ctx.db
|
||||||
|
.query("transactions")
|
||||||
|
.withIndex("by_user_booking", (index) =>
|
||||||
|
index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to),
|
||||||
|
)
|
||||||
|
.order("desc");
|
||||||
|
for await (const tx of fallback) {
|
||||||
|
if (tx.effectiveMonth === undefined) transactions.push(tx);
|
||||||
|
}
|
||||||
|
return sortTransactionsForContext(transactions, args.basis);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.accountId) {
|
||||||
|
const accountId = args.accountId;
|
||||||
|
const q = ctx.db
|
||||||
|
.query("transactions")
|
||||||
|
.withIndex("by_user_account_booking", (index) =>
|
||||||
|
index
|
||||||
|
.eq("userId", userId)
|
||||||
|
.eq("accountId", accountId)
|
||||||
|
.gte("bookingDate", args.from)
|
||||||
|
.lte("bookingDate", args.to),
|
||||||
|
)
|
||||||
|
.order("desc");
|
||||||
|
for await (const tx of q) transactions.push(tx);
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = ctx.db
|
||||||
|
.query("transactions")
|
||||||
|
.withIndex("by_user_booking", (index) =>
|
||||||
|
index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to),
|
||||||
|
)
|
||||||
|
.order("desc");
|
||||||
|
for await (const tx of q) transactions.push(tx);
|
||||||
|
return sortTransactionsForContext(transactions, args.basis);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTotals(transactions: Doc<"transactions">[]) {
|
||||||
|
const totals = transactions.reduce(
|
||||||
|
(acc, tx) => {
|
||||||
|
if (tx.amount > 0) acc.income += tx.amount;
|
||||||
|
if (tx.amount < 0) acc.expenses += tx.amount;
|
||||||
|
acc.balance += tx.amount;
|
||||||
|
acc.transactionCount += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ income: 0, expenses: 0, balance: 0, transactionCount: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactionCount: totals.transactionCount,
|
||||||
|
income: Math.round(totals.income * 100) / 100,
|
||||||
|
expenses: Math.round(totals.expenses * 100) / 100,
|
||||||
|
balance: Math.round(totals.balance * 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildContextSummary(
|
||||||
|
ctx: QueryCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
args: ChatContextArgs,
|
||||||
|
): Promise<{ summary: ChatContextSummary; transactions: Doc<"transactions">[] }> {
|
||||||
|
const transactions = await loadMatchingTransactions(ctx, userId, args);
|
||||||
|
const account = args.accountId ? await ctx.db.get(args.accountId) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
from: args.from,
|
||||||
|
to: args.to,
|
||||||
|
basis: args.basis,
|
||||||
|
accountId: args.accountId,
|
||||||
|
accountName: account?.userId === userId ? account.name : undefined,
|
||||||
|
totals: calculateTotals(transactions),
|
||||||
|
isComplete: true,
|
||||||
|
},
|
||||||
|
transactions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextArgsValidator = {
|
||||||
|
from: v.string(),
|
||||||
|
to: v.string(),
|
||||||
|
accountId: v.optional(v.id("accounts")),
|
||||||
|
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalsValidator = v.object({
|
||||||
|
transactionCount: v.number(),
|
||||||
|
income: v.number(),
|
||||||
|
expenses: v.number(),
|
||||||
|
balance: v.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextSummaryValidator = v.object({
|
||||||
|
from: v.string(),
|
||||||
|
to: v.string(),
|
||||||
|
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||||
|
accountId: v.optional(v.id("accounts")),
|
||||||
|
accountName: v.optional(v.string()),
|
||||||
|
totals: totalsValidator,
|
||||||
|
isComplete: v.literal(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getContext = query({
|
||||||
|
args: contextArgsValidator,
|
||||||
|
returns: contextSummaryValidator,
|
||||||
|
handler: async (ctx, args): Promise<ChatContextSummary> => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const { summary } = await buildContextSummary(ctx, userId, args);
|
||||||
|
return summary;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function toDisplayContextLine(
|
||||||
|
tx: Doc<"transactions">,
|
||||||
|
categoryById: Map<Id<"categories">, string>,
|
||||||
|
accountById: Map<Id<"accounts">, string>,
|
||||||
|
) {
|
||||||
|
const date = tx.valueDate || tx.bookingDate || "n/a";
|
||||||
|
const amount = formatEuro(tx.amount);
|
||||||
|
const name = tx.counterparty ?? "–";
|
||||||
|
const category = tx.categoryId ? categoryById.get(tx.categoryId) : "Ohne Kategorie";
|
||||||
|
const account = tx.accountId ? accountById.get(tx.accountId) : "Ohne Konto";
|
||||||
|
return `${date} | ${tx.description} (${name}) | ${amount} | ${category ?? "Ohne Kategorie"} | ${account ?? "Ohne Konto"}${
|
||||||
|
tx.isPending ? " | offen" : ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPromptContext = internalQuery({
|
||||||
|
args: contextArgsValidator,
|
||||||
|
returns: v.object({
|
||||||
|
...contextSummaryValidator.fields,
|
||||||
|
transactionLines: v.array(v.string()),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args): Promise<ChatPromptContext> => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const { summary, transactions } = await buildContextSummary(ctx, userId, args);
|
||||||
|
|
||||||
|
const categories = await ctx.db
|
||||||
|
.query("categories")
|
||||||
|
.withIndex("by_user", (index) => index.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
const accounts = await ctx.db
|
||||||
|
.query("accounts")
|
||||||
|
.withIndex("by_user", (index) => index.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
const categoryById = new Map(categories.map((category) => [category._id, category.name]));
|
||||||
|
const accountById = new Map(accounts.map((account) => [account._id, account.name]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
transactionLines: transactions.map((tx) =>
|
||||||
|
toDisplayContextLine(tx, categoryById, accountById),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ask = action({
|
||||||
|
args: {
|
||||||
|
messages: v.array(chatMessageValidator),
|
||||||
|
from: v.string(),
|
||||||
|
to: v.string(),
|
||||||
|
accountId: v.optional(v.id("accounts")),
|
||||||
|
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
model: v.string(),
|
||||||
|
answer: v.string(),
|
||||||
|
usedTransactions: v.number(),
|
||||||
|
usedBalance: v.object({
|
||||||
|
income: v.number(),
|
||||||
|
expenses: v.number(),
|
||||||
|
balance: v.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||||
|
if (args.messages.length === 0) {
|
||||||
|
throw new Error("Kein Nutzernachrichttext vorhanden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
|
throw new Error(
|
||||||
|
"OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireUserId(ctx);
|
||||||
|
|
||||||
|
const context: ChatPromptContext = await ctx.runQuery(internal.savingsChat.getPromptContext, {
|
||||||
|
from: args.from,
|
||||||
|
to: args.to,
|
||||||
|
accountId: args.accountId,
|
||||||
|
basis: args.basis,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastMessages = args.messages
|
||||||
|
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
|
||||||
|
.slice(-MAX_CONVERSATION_MESSAGES);
|
||||||
|
|
||||||
|
const prompt = buildPrompt(context, lastMessages);
|
||||||
|
if (prompt.length > MAX_PROMPT_CHARACTERS) {
|
||||||
|
throw new Error(
|
||||||
|
"Der ausgewählte Zeitraum enthält zu viele Umsatzdetails für eine vollständige KI-Anfrage. Bitte Zeitraum oder Konto eingrenzen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = buildSystemPrompt(context);
|
||||||
|
|
||||||
|
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
|
||||||
|
const candidates = [
|
||||||
|
envModel,
|
||||||
|
"gpt-5.4-mini",
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
"gpt-4.1",
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const modelName of candidates) {
|
||||||
|
try {
|
||||||
|
const result = await generateText({
|
||||||
|
model: openai(modelName),
|
||||||
|
system,
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
model: modelName,
|
||||||
|
answer: result.text,
|
||||||
|
usedTransactions: context.totals.transactionCount,
|
||||||
|
usedBalance: {
|
||||||
|
income: context.totals.income,
|
||||||
|
expenses: context.totals.expenses,
|
||||||
|
balance: context.totals.balance,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
lastError instanceof Error
|
||||||
|
? lastError.message
|
||||||
|
: "Unbekannter Fehler bei der KI-Anfrage";
|
||||||
|
throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -64,8 +64,14 @@ export default defineSchema({
|
|||||||
.index("by_user_effmonth", ["userId", "effectiveMonth"])
|
.index("by_user_effmonth", ["userId", "effectiveMonth"])
|
||||||
.index("by_user_category", ["userId", "categoryId"])
|
.index("by_user_category", ["userId", "categoryId"])
|
||||||
.index("by_user_account", ["userId", "accountId"])
|
.index("by_user_account", ["userId", "accountId"])
|
||||||
|
.index("by_user_account_booking", ["userId", "accountId", "bookingDate"])
|
||||||
|
.index("by_user_account_effmonth", ["userId", "accountId", "effectiveMonth"])
|
||||||
.index("by_user_dedup", ["userId", "dedupHash"])
|
.index("by_user_dedup", ["userId", "dedupHash"])
|
||||||
.index("by_user_extref", ["userId", "externalRef"]),
|
.index("by_user_extref", ["userId", "externalRef"])
|
||||||
|
.searchIndex("search_description", {
|
||||||
|
searchField: "description",
|
||||||
|
filterFields: ["userId"],
|
||||||
|
}),
|
||||||
|
|
||||||
loans: defineTable({
|
loans: defineTable({
|
||||||
userId: v.id("users"),
|
userId: v.id("users"),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const list = query({
|
|||||||
from: v.optional(v.string()),
|
from: v.optional(v.string()),
|
||||||
to: v.optional(v.string()),
|
to: v.optional(v.string()),
|
||||||
categoryIds: v.optional(v.array(v.id("categories"))),
|
categoryIds: v.optional(v.array(v.id("categories"))),
|
||||||
|
withoutCategory: v.optional(v.boolean()),
|
||||||
accountId: v.optional(v.id("accounts")),
|
accountId: v.optional(v.id("accounts")),
|
||||||
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
|
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
|
||||||
pendingOnly: v.optional(v.boolean()),
|
pendingOnly: v.optional(v.boolean()),
|
||||||
@@ -50,48 +51,57 @@ export const list = query({
|
|||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const userId = await requireUserId(ctx);
|
const userId = await requireUserId(ctx);
|
||||||
let q = ctx.db
|
|
||||||
|
let q;
|
||||||
|
if (args.search) {
|
||||||
|
q = ctx.db
|
||||||
.query("transactions")
|
.query("transactions")
|
||||||
.withIndex("by_user_booking", (q) => q.eq("userId", userId))
|
.withSearchIndex("search_description", (sq) =>
|
||||||
.order("desc");
|
sq.search("description", args.search!).eq("userId", userId),
|
||||||
|
);
|
||||||
const result = await q.paginate(args.paginationOpts);
|
} else {
|
||||||
let page = result.page;
|
q = ctx.db
|
||||||
|
.query("transactions")
|
||||||
|
.withIndex("by_user_booking", (iq) => {
|
||||||
|
if (args.from && args.to) {
|
||||||
|
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
||||||
|
}
|
||||||
if (args.from) {
|
if (args.from) {
|
||||||
page = page.filter((tx) => !tx.bookingDate || tx.bookingDate >= args.from!);
|
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
||||||
}
|
}
|
||||||
if (args.to) {
|
if (args.to) {
|
||||||
page = page.filter((tx) => !tx.bookingDate || tx.bookingDate <= args.to!);
|
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
||||||
}
|
}
|
||||||
if (args.accountId) {
|
return iq.eq("userId", userId);
|
||||||
page = page.filter((tx) => tx.accountId === args.accountId);
|
})
|
||||||
}
|
.order("desc");
|
||||||
if (args.pendingOnly) {
|
|
||||||
page = page.filter((tx) => tx.isPending);
|
|
||||||
}
|
|
||||||
if (args.type === "einnahme") {
|
|
||||||
page = page.filter((tx) => tx.amount > 0);
|
|
||||||
}
|
|
||||||
if (args.type === "ausgabe") {
|
|
||||||
page = page.filter((tx) => tx.amount < 0);
|
|
||||||
}
|
|
||||||
if (args.categoryIds && args.categoryIds.length > 0) {
|
|
||||||
const set = new Set(args.categoryIds);
|
|
||||||
page = page.filter((tx) => tx.categoryId && set.has(tx.categoryId));
|
|
||||||
}
|
|
||||||
if (args.search) {
|
|
||||||
const s = args.search.toLowerCase();
|
|
||||||
page = page.filter(
|
|
||||||
(tx) =>
|
|
||||||
tx.description.toLowerCase().includes(s) ||
|
|
||||||
(tx.counterparty?.toLowerCase().includes(s) ?? false) ||
|
|
||||||
(tx.rawText?.toLowerCase().includes(s) ?? false),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.pendingOnly) {
|
||||||
|
q = q.filter((f) => f.eq(f.field("isPending"), true));
|
||||||
|
}
|
||||||
|
if (args.accountId) {
|
||||||
|
q = q.filter((f) => f.eq(f.field("accountId"), args.accountId));
|
||||||
|
}
|
||||||
|
if (args.type === "einnahme") {
|
||||||
|
q = q.filter((f) => f.gt(f.field("amount"), 0));
|
||||||
|
}
|
||||||
|
if (args.type === "ausgabe") {
|
||||||
|
q = q.filter((f) => f.lt(f.field("amount"), 0));
|
||||||
|
}
|
||||||
|
if (args.categoryIds && args.categoryIds.length > 0) {
|
||||||
|
q = q.filter((f) =>
|
||||||
|
f.or(...args.categoryIds!.map((id) => f.eq(f.field("categoryId"), id))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.withoutCategory) {
|
||||||
|
q = q.filter((f) => f.eq(f.field("categoryId"), undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await q.paginate(args.paginationOpts);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page,
|
page: result.page,
|
||||||
isDone: result.isDone,
|
isDone: result.isDone,
|
||||||
continueCursor: result.continueCursor,
|
continueCursor: result.continueCursor,
|
||||||
};
|
};
|
||||||
|
|||||||
668
package-lock.json
generated
668
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "finanz-dashboard",
|
"name": "finanz-dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^3.0.71",
|
||||||
"@auth/core": "^0.41.2",
|
"@auth/core": "^0.41.2",
|
||||||
"@convex-dev/auth": "^0.0.94",
|
"@convex-dev/auth": "^0.0.94",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
@@ -26,11 +27,14 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tooltip": "^1.2.9",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.2",
|
||||||
|
"ai": "^6.0.205",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"convex": "^1.41.0",
|
"convex": "^1.41.0",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"lib-fints": "^1.4.8",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -44,6 +48,7 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@edge-runtime/vm": "^5.0.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
@@ -51,6 +56,7 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"convex-test": "^0.0.53",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.5.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
@@ -58,7 +64,70 @@
|
|||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/gateway": {
|
||||||
|
"version": "3.0.131",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.131.tgz",
|
||||||
|
"integrity": "sha512-CnjOZdywQaUnCyZ0N5wVNm7Sm63+NeHDVZQJKFX2IDq+t03SLwiiuoi3ILTLPlM+YSOhkQ/pvIDoR4qa98Zp5A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.10",
|
||||||
|
"@ai-sdk/provider-utils": "4.0.29",
|
||||||
|
"@vercel/oidc": "3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/openai": {
|
||||||
|
"version": "3.0.71",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.71.tgz",
|
||||||
|
"integrity": "sha512-j6eBAa5oHFZ4U5CxpIV3T4zXNM/BviodNCZCL1qHkA4aqkwK9iQ18TWYz2DZcXpw4BO5pikKzqpXORxb1EnZGA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.10",
|
||||||
|
"@ai-sdk/provider-utils": "4.0.29"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider": {
|
||||||
|
"version": "3.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
|
||||||
|
"integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider-utils": {
|
||||||
|
"version": "4.0.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.29.tgz",
|
||||||
|
"integrity": "sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.10",
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"eventsource-parser": "^3.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@auth/core": {
|
"node_modules/@auth/core": {
|
||||||
@@ -376,6 +445,29 @@
|
|||||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@edge-runtime/primitives": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@edge-runtime/vm": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@edge-runtime/primitives": "6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -1139,6 +1231,27 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@emnapi/runtime": "^1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@opentelemetry/api": {
|
||||||
|
"version": "1.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
|
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oslojs/asn1": {
|
"node_modules/@oslojs/asn1": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
|
||||||
@@ -2786,6 +2899,23 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.14.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||||
|
"integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.17.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/table-core": {
|
"node_modules/@tanstack/table-core": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
@@ -2799,6 +2929,16 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
|
||||||
|
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -2810,6 +2950,17 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
@@ -2873,6 +3024,13 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -3183,6 +3341,15 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vercel/oidc": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
|
||||||
@@ -3209,6 +3376,119 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@@ -3232,6 +3512,24 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ai": {
|
||||||
|
"version": "6.0.205",
|
||||||
|
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.205.tgz",
|
||||||
|
"integrity": "sha512-F4akEGF41UdgJO3L4v+D5noVD1/czhJy6x0k9R/i1EXfxqrkBh/PdYSgRSLPiGFvrw76dzI8h4w3NYmLrTb8dw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/gateway": "3.0.131",
|
||||||
|
"@ai-sdk/provider": "3.0.10",
|
||||||
|
"@ai-sdk/provider-utils": "4.0.29",
|
||||||
|
"@opentelemetry/api": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -3249,6 +3547,18 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/anynum": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
@@ -3261,6 +3571,16 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -3352,6 +3672,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
@@ -3434,6 +3764,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/convex-test": {
|
||||||
|
"version": "0.0.53",
|
||||||
|
"resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.53.tgz",
|
||||||
|
"integrity": "sha512-bouZQTnTvZi8IHljHL++yClj1vcV+/9ZxEcd8JZz7RDxOfPkRKrkMgkk/xlX4M1EAiwcEJPNiQE7VJFvDM3lCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"convex": "^1.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
@@ -3668,6 +4008,13 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-toolkit": {
|
"node_modules/es-toolkit": {
|
||||||
"version": "1.47.1",
|
"version": "1.47.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
|
||||||
@@ -3917,6 +4264,16 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -3933,6 +4290,25 @@
|
|||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -3954,6 +4330,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-xml-builder": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"xml-naming": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.0.tgz",
|
||||||
|
"integrity": "sha512-duBuXbyIhEeNO4GjFuVqr0nF047oNwr18aum+zJyqo0MUG/n7Afgs3Qv3D6VN3ONedUKxiuFlPiMGIa0Z11chA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodable/entities": "^2.2.0",
|
||||||
|
"fast-xml-builder": "^1.2.0",
|
||||||
|
"is-unsafe": "^1.0.1",
|
||||||
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"strnum": "^2.4.0",
|
||||||
|
"xml-naming": "^0.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -4181,6 +4596,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-unsafe": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -4234,6 +4661,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||||
|
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -4294,6 +4727,18 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lib-fints": {
|
||||||
|
"version": "1.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/lib-fints/-/lib-fints-1.4.8.tgz",
|
||||||
|
"integrity": "sha512-MrkTHuZDXLaRjURNetQUMYiZ1qKflO6m3/oNq5zs67NkyQxemMuUxO33FLr2OHWyA4mL/QTyHI/ICqrunQqnwA==",
|
||||||
|
"license": "LGPL-2.1-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-parser": "^5.8.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -4691,6 +5136,20 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -4757,6 +5216,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -4773,6 +5247,13 @@
|
|||||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -5212,6 +5693,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
@@ -5232,6 +5720,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anynum": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||||
@@ -5269,6 +5786,23 @@
|
|||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -5286,6 +5820,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -5556,6 +6100,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.9",
|
||||||
|
"@vitest/mocker": "4.1.9",
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/runner": "4.1.9",
|
||||||
|
"@vitest/snapshot": "4.1.9",
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.9",
|
||||||
|
"@vitest/browser-preview": "4.1.9",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.9",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.9",
|
||||||
|
"@vitest/coverage-v8": "4.1.9",
|
||||||
|
"@vitest/ui": "4.1.9",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -5572,6 +6206,23 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -5603,6 +6254,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-naming": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^3.0.71",
|
||||||
"@auth/core": "^0.41.2",
|
"@auth/core": "^0.41.2",
|
||||||
"@convex-dev/auth": "^0.0.94",
|
"@convex-dev/auth": "^0.0.94",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
@@ -30,6 +31,8 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tooltip": "^1.2.9",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.2",
|
||||||
|
"ai": "^6.0.205",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@edge-runtime/vm": "^5.0.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
@@ -56,6 +60,7 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"convex-test": "^0.0.53",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.5.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
@@ -63,6 +68,7 @@
|
|||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
377
pnpm-lock.yaml
generated
377
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@ai-sdk/openai':
|
||||||
|
specifier: ^3.0.71
|
||||||
|
version: 3.0.71(zod@4.4.3)
|
||||||
'@auth/core':
|
'@auth/core':
|
||||||
specifier: ^0.41.2
|
specifier: ^0.41.2
|
||||||
version: 0.41.2
|
version: 0.41.2
|
||||||
@@ -62,6 +65,12 @@ importers:
|
|||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.14.2
|
||||||
|
version: 3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
ai:
|
||||||
|
specifier: ^6.0.205
|
||||||
|
version: 6.0.205(zod@4.4.3)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -114,6 +123,9 @@ importers:
|
|||||||
specifier: ^4.4.3
|
specifier: ^4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(eslint@10.5.0(jiti@2.7.0))
|
version: 10.0.1(eslint@10.5.0(jiti@2.7.0))
|
||||||
@@ -135,6 +147,9 @@ importers:
|
|||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.1
|
specifier: ^6.0.1
|
||||||
version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))
|
version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))
|
||||||
|
convex-test:
|
||||||
|
specifier: ^0.0.53
|
||||||
|
version: 0.0.53(convex@1.41.0(react@19.2.7))
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.5.0
|
specifier: ^10.5.0
|
||||||
version: 10.5.0(jiti@2.7.0)
|
version: 10.5.0(jiti@2.7.0)
|
||||||
@@ -159,9 +174,34 @@ importers:
|
|||||||
vite:
|
vite:
|
||||||
specifier: ^8.0.12
|
specifier: ^8.0.12
|
||||||
version: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)
|
version: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.1.9
|
||||||
|
version: 4.1.9(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@ai-sdk/gateway@3.0.131':
|
||||||
|
resolution: {integrity: sha512-CnjOZdywQaUnCyZ0N5wVNm7Sm63+NeHDVZQJKFX2IDq+t03SLwiiuoi3ILTLPlM+YSOhkQ/pvIDoR4qa98Zp5A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.76 || ^4.1.8
|
||||||
|
|
||||||
|
'@ai-sdk/openai@3.0.71':
|
||||||
|
resolution: {integrity: sha512-j6eBAa5oHFZ4U5CxpIV3T4zXNM/BviodNCZCL1qHkA4aqkwK9iQ18TWYz2DZcXpw4BO5pikKzqpXORxb1EnZGA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.76 || ^4.1.8
|
||||||
|
|
||||||
|
'@ai-sdk/provider-utils@4.0.29':
|
||||||
|
resolution: {integrity: sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.76 || ^4.1.8
|
||||||
|
|
||||||
|
'@ai-sdk/provider@3.0.10':
|
||||||
|
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@auth/core@0.41.2':
|
'@auth/core@0.41.2':
|
||||||
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
|
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -257,6 +297,14 @@ packages:
|
|||||||
'@date-fns/tz@1.5.0':
|
'@date-fns/tz@1.5.0':
|
||||||
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
||||||
|
|
||||||
|
'@edge-runtime/primitives@6.0.0':
|
||||||
|
resolution: {integrity: sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@edge-runtime/vm@5.0.0':
|
||||||
|
resolution: {integrity: sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||||
|
|
||||||
@@ -526,6 +574,10 @@ packages:
|
|||||||
'@nodable/entities@2.2.0':
|
'@nodable/entities@2.2.0':
|
||||||
resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==}
|
resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.1':
|
||||||
|
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
'@oslojs/asn1@1.0.0':
|
'@oslojs/asn1@1.0.0':
|
||||||
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
|
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
|
||||||
|
|
||||||
@@ -1216,13 +1268,25 @@ packages:
|
|||||||
react: '>=16.8'
|
react: '>=16.8'
|
||||||
react-dom: '>=16.8'
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.14.2':
|
||||||
|
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@tanstack/table-core@8.21.3':
|
'@tanstack/table-core@8.21.3':
|
||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.0':
|
||||||
|
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.2':
|
'@tybys/wasm-util@0.10.2':
|
||||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
'@types/d3-array@3.2.2':
|
'@types/d3-array@3.2.2':
|
||||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
@@ -1250,6 +1314,9 @@ packages:
|
|||||||
'@types/d3-timer@3.0.2':
|
'@types/d3-timer@3.0.2':
|
||||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2':
|
||||||
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
'@types/esrecurse@4.3.1':
|
'@types/esrecurse@4.3.1':
|
||||||
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
||||||
|
|
||||||
@@ -1335,6 +1402,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==}
|
resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@vercel/oidc@3.2.0':
|
||||||
|
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.2':
|
'@vitejs/plugin-react@6.0.2':
|
||||||
resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
|
resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1348,6 +1419,35 @@ packages:
|
|||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/expect@4.1.9':
|
||||||
|
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||||
|
|
||||||
|
'@vitest/mocker@4.1.9':
|
||||||
|
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/pretty-format@4.1.9':
|
||||||
|
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||||
|
|
||||||
|
'@vitest/runner@4.1.9':
|
||||||
|
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@4.1.9':
|
||||||
|
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||||
|
|
||||||
|
'@vitest/spy@4.1.9':
|
||||||
|
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||||
|
|
||||||
|
'@vitest/utils@4.1.9':
|
||||||
|
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1358,6 +1458,12 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ai@6.0.205:
|
||||||
|
resolution: {integrity: sha512-F4akEGF41UdgJO3L4v+D5noVD1/czhJy6x0k9R/i1EXfxqrkBh/PdYSgRSLPiGFvrw76dzI8h4w3NYmLrTb8dw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.76 || ^4.1.8
|
||||||
|
|
||||||
ajv@6.15.0:
|
ajv@6.15.0:
|
||||||
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
|
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
|
||||||
|
|
||||||
@@ -1368,6 +1474,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
balanced-match@4.0.4:
|
balanced-match@4.0.4:
|
||||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -1389,6 +1499,10 @@ packages:
|
|||||||
caniuse-lite@1.0.30001799:
|
caniuse-lite@1.0.30001799:
|
||||||
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
|
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
|
||||||
|
|
||||||
|
chai@6.2.2:
|
||||||
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
@@ -1405,6 +1519,11 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
convex-test@0.0.53:
|
||||||
|
resolution: {integrity: sha512-bouZQTnTvZi8IHljHL++yClj1vcV+/9ZxEcd8JZz7RDxOfPkRKrkMgkk/xlX4M1EAiwcEJPNiQE7VJFvDM3lCQ==}
|
||||||
|
peerDependencies:
|
||||||
|
convex: ^1.32.0
|
||||||
|
|
||||||
convex@1.41.0:
|
convex@1.41.0:
|
||||||
resolution: {integrity: sha512-euxVf6yfpB7/VGKOobkLgjpbJidsUgW+b0ezonEyCUPqlpHFwR4/yIiI1hjjErzraiw91GxrtxpXQClMLNqU+w==}
|
resolution: {integrity: sha512-euxVf6yfpB7/VGKOobkLgjpbJidsUgW+b0ezonEyCUPqlpHFwR4/yIiI1hjjErzraiw91GxrtxpXQClMLNqU+w==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
|
||||||
@@ -1511,6 +1630,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
es-module-lexer@2.1.0:
|
||||||
|
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||||
|
|
||||||
es-toolkit@1.47.1:
|
es-toolkit@1.47.1:
|
||||||
resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==}
|
resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==}
|
||||||
|
|
||||||
@@ -1576,6 +1698,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
esutils@2.0.3:
|
esutils@2.0.3:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1583,6 +1708,14 @@ packages:
|
|||||||
eventemitter3@5.0.4:
|
eventemitter3@5.0.4:
|
||||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
|
eventsource-parser@3.1.0:
|
||||||
|
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
expect-type@1.3.0:
|
||||||
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
@@ -1714,6 +1847,9 @@ packages:
|
|||||||
json-schema-traverse@0.4.1:
|
json-schema-traverse@0.4.1:
|
||||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||||
|
|
||||||
|
json-schema@0.4.0:
|
||||||
|
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
|
||||||
|
|
||||||
json-stable-stringify-without-jsonify@1.0.1:
|
json-stable-stringify-without-jsonify@1.0.1:
|
||||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||||
|
|
||||||
@@ -1852,6 +1988,10 @@ packages:
|
|||||||
oauth4webapi@3.8.6:
|
oauth4webapi@3.8.6:
|
||||||
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
||||||
|
|
||||||
|
obug@2.1.3:
|
||||||
|
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1882,6 +2022,9 @@ packages:
|
|||||||
path-to-regexp@6.3.0:
|
path-to-regexp@6.3.0:
|
||||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -2051,6 +2194,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
sonner@2.0.7:
|
sonner@2.0.7:
|
||||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2061,6 +2207,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
std-env@4.1.0:
|
||||||
|
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||||
|
|
||||||
strnum@2.4.0:
|
strnum@2.4.0:
|
||||||
resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==}
|
resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==}
|
||||||
|
|
||||||
@@ -2077,10 +2229,21 @@ packages:
|
|||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
|
tinybench@2.9.0:
|
||||||
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
tinyexec@1.2.4:
|
||||||
|
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tinyglobby@0.2.17:
|
tinyglobby@0.2.17:
|
||||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinyrainbow@3.1.0:
|
||||||
|
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
ts-api-utils@2.5.0:
|
ts-api-utils@2.5.0:
|
||||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
@@ -2189,11 +2352,57 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@4.1.9:
|
||||||
|
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||||
|
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@opentelemetry/api': ^1.9.0
|
||||||
|
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||||
|
'@vitest/browser-playwright': 4.1.9
|
||||||
|
'@vitest/browser-preview': 4.1.9
|
||||||
|
'@vitest/browser-webdriverio': 4.1.9
|
||||||
|
'@vitest/coverage-istanbul': 4.1.9
|
||||||
|
'@vitest/coverage-v8': 4.1.9
|
||||||
|
'@vitest/ui': 4.1.9
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@opentelemetry/api':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-playwright':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-preview':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-webdriverio':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-istanbul':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2232,6 +2441,30 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@ai-sdk/gateway@3.0.131(zod@4.4.3)':
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/provider': 3.0.10
|
||||||
|
'@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
|
||||||
|
'@vercel/oidc': 3.2.0
|
||||||
|
zod: 4.4.3
|
||||||
|
|
||||||
|
'@ai-sdk/openai@3.0.71(zod@4.4.3)':
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/provider': 3.0.10
|
||||||
|
'@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
|
||||||
|
zod: 4.4.3
|
||||||
|
|
||||||
|
'@ai-sdk/provider-utils@4.0.29(zod@4.4.3)':
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/provider': 3.0.10
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
eventsource-parser: 3.1.0
|
||||||
|
zod: 4.4.3
|
||||||
|
|
||||||
|
'@ai-sdk/provider@3.0.10':
|
||||||
|
dependencies:
|
||||||
|
json-schema: 0.4.0
|
||||||
|
|
||||||
'@auth/core@0.41.2':
|
'@auth/core@0.41.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@panva/hkdf': 1.2.1
|
'@panva/hkdf': 1.2.1
|
||||||
@@ -2359,6 +2592,12 @@ snapshots:
|
|||||||
|
|
||||||
'@date-fns/tz@1.5.0': {}
|
'@date-fns/tz@1.5.0': {}
|
||||||
|
|
||||||
|
'@edge-runtime/primitives@6.0.0': {}
|
||||||
|
|
||||||
|
'@edge-runtime/vm@5.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@edge-runtime/primitives': 6.0.0
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.1
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
@@ -2553,6 +2792,8 @@ snapshots:
|
|||||||
|
|
||||||
'@nodable/entities@2.2.0': {}
|
'@nodable/entities@2.2.0': {}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.1': {}
|
||||||
|
|
||||||
'@oslojs/asn1@1.0.0':
|
'@oslojs/asn1@1.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@oslojs/binary': 1.0.0
|
'@oslojs/binary': 1.0.0
|
||||||
@@ -3177,13 +3418,26 @@ snapshots:
|
|||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.17.0
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
'@tanstack/table-core@8.21.3': {}
|
'@tanstack/table-core@8.21.3': {}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.0': {}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.2':
|
'@tybys/wasm-util@0.10.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/deep-eql': 4.0.2
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
'@types/d3-array@3.2.2': {}
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
'@types/d3-color@3.1.3': {}
|
'@types/d3-color@3.1.3': {}
|
||||||
@@ -3208,6 +3462,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-timer@3.0.2': {}
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/esrecurse@4.3.1': {}
|
'@types/esrecurse@4.3.1': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
@@ -3323,17 +3579,68 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.61.0
|
'@typescript-eslint/types': 8.61.0
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
|
'@vercel/oidc@3.2.0': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))':
|
'@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)
|
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)
|
||||||
|
|
||||||
|
'@vitest/expect@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/spy': 4.1.9
|
||||||
|
'@vitest/utils': 4.1.9
|
||||||
|
chai: 6.2.2
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/mocker@4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 4.1.9
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)
|
||||||
|
|
||||||
|
'@vitest/pretty-format@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/runner@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 4.1.9
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/snapshot@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 4.1.9
|
||||||
|
'@vitest/utils': 4.1.9
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/spy@4.1.9': {}
|
||||||
|
|
||||||
|
'@vitest/utils@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 4.1.9
|
||||||
|
convert-source-map: 2.0.0
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.17.0):
|
acorn-jsx@5.3.2(acorn@8.17.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.17.0
|
acorn: 8.17.0
|
||||||
|
|
||||||
acorn@8.17.0: {}
|
acorn@8.17.0: {}
|
||||||
|
|
||||||
|
ai@6.0.205(zod@4.4.3):
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/gateway': 3.0.131(zod@4.4.3)
|
||||||
|
'@ai-sdk/provider': 3.0.10
|
||||||
|
'@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
|
zod: 4.4.3
|
||||||
|
|
||||||
ajv@6.15.0:
|
ajv@6.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@@ -3347,6 +3654,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.37: {}
|
baseline-browser-mapping@2.10.37: {}
|
||||||
@@ -3365,6 +3674,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001799: {}
|
caniuse-lite@1.0.30001799: {}
|
||||||
|
|
||||||
|
chai@6.2.2: {}
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@@ -3385,6 +3696,10 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
convex-test@0.0.53(convex@1.41.0(react@19.2.7)):
|
||||||
|
dependencies:
|
||||||
|
convex: 1.41.0(react@19.2.7)
|
||||||
|
|
||||||
convex@1.41.0(react@19.2.7):
|
convex@1.41.0(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.0
|
esbuild: 0.27.0
|
||||||
@@ -3465,6 +3780,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
|
es-module-lexer@2.1.0: {}
|
||||||
|
|
||||||
es-toolkit@1.47.1: {}
|
es-toolkit@1.47.1: {}
|
||||||
|
|
||||||
esbuild@0.27.0:
|
esbuild@0.27.0:
|
||||||
@@ -3579,10 +3896,18 @@ snapshots:
|
|||||||
|
|
||||||
estraverse@5.3.0: {}
|
estraverse@5.3.0: {}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
eventemitter3@5.0.4: {}
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
|
eventsource-parser@3.1.0: {}
|
||||||
|
|
||||||
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
@@ -3679,6 +4004,8 @@ snapshots:
|
|||||||
|
|
||||||
json-schema-traverse@0.4.1: {}
|
json-schema-traverse@0.4.1: {}
|
||||||
|
|
||||||
|
json-schema@0.4.0: {}
|
||||||
|
|
||||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
@@ -3782,6 +4109,8 @@ snapshots:
|
|||||||
|
|
||||||
oauth4webapi@3.8.6: {}
|
oauth4webapi@3.8.6: {}
|
||||||
|
|
||||||
|
obug@2.1.3: {}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
@@ -3809,6 +4138,8 @@ snapshots:
|
|||||||
|
|
||||||
path-to-regexp@6.3.0: {}
|
path-to-regexp@6.3.0: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
@@ -3967,6 +4298,8 @@ snapshots:
|
|||||||
|
|
||||||
shebang-regex@3.0.0: {}
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
@@ -3974,6 +4307,10 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@4.1.0: {}
|
||||||
|
|
||||||
strnum@2.4.0:
|
strnum@2.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anynum: 1.0.0
|
anynum: 1.0.0
|
||||||
@@ -3986,11 +4323,17 @@ snapshots:
|
|||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinyexec@1.2.4: {}
|
||||||
|
|
||||||
tinyglobby@0.2.17:
|
tinyglobby@0.2.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinyrainbow@3.1.0: {}
|
||||||
|
|
||||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
@@ -4075,10 +4418,44 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
|
|
||||||
|
vitest@4.1.9(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)):
|
||||||
|
dependencies:
|
||||||
|
'@vitest/expect': 4.1.9
|
||||||
|
'@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))
|
||||||
|
'@vitest/pretty-format': 4.1.9
|
||||||
|
'@vitest/runner': 4.1.9
|
||||||
|
'@vitest/snapshot': 4.1.9
|
||||||
|
'@vitest/spy': 4.1.9
|
||||||
|
'@vitest/utils': 4.1.9
|
||||||
|
es-module-lexer: 2.1.0
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
obug: 2.1.3
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.4
|
||||||
|
std-env: 4.1.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 1.2.4
|
||||||
|
tinyglobby: 0.2.17
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@edge-runtime/vm': 5.0.0
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
|
'@types/node': 24.13.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- msw
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
ws@8.20.1: {}
|
ws@8.20.1: {}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DashboardPage } from "./pages/DashboardPage";
|
|||||||
import { TransactionsPage } from "./pages/TransactionsPage";
|
import { TransactionsPage } from "./pages/TransactionsPage";
|
||||||
import { CategoriesPage } from "./pages/CategoriesPage";
|
import { CategoriesPage } from "./pages/CategoriesPage";
|
||||||
import { LoansPage } from "./pages/LoansPage";
|
import { LoansPage } from "./pages/LoansPage";
|
||||||
|
import { SavingsChatPage } from "./pages/SavingsChatPage";
|
||||||
import { ImportPage } from "./pages/ImportPage";
|
import { ImportPage } from "./pages/ImportPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { Skeleton } from "./components/ui/skeleton";
|
import { Skeleton } from "./components/ui/skeleton";
|
||||||
@@ -49,6 +50,7 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ path: "/", element: <DashboardPage /> },
|
{ path: "/", element: <DashboardPage /> },
|
||||||
{ path: "/transaktionen", element: <TransactionsPage /> },
|
{ path: "/transaktionen", element: <TransactionsPage /> },
|
||||||
|
{ path: "/talk", element: <SavingsChatPage /> },
|
||||||
{ path: "/kategorien", element: <CategoriesPage /> },
|
{ path: "/kategorien", element: <CategoriesPage /> },
|
||||||
{ path: "/kredite", element: <LoansPage /> },
|
{ path: "/kredite", element: <LoansPage /> },
|
||||||
{ path: "/import", element: <ImportPage /> },
|
{ path: "/import", element: <ImportPage /> },
|
||||||
|
|||||||
36
src/components/charts/CategoryBreakdownChart.test.ts
Normal file
36
src/components/charts/CategoryBreakdownChart.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { toCategoryPieData } from "./categoryBreakdownData";
|
||||||
|
|
||||||
|
describe("toCategoryPieData", () => {
|
||||||
|
test("uses positive chart values while preserving signed expense amounts", () => {
|
||||||
|
expect(
|
||||||
|
toCategoryPieData([
|
||||||
|
{
|
||||||
|
name: "Lebensmittel",
|
||||||
|
amount: -123.45,
|
||||||
|
color: "#ef4444",
|
||||||
|
block: "variabel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rueckerstattung",
|
||||||
|
amount: 12,
|
||||||
|
color: "#22c55e",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
name: "Lebensmittel",
|
||||||
|
amount: -123.45,
|
||||||
|
chartAmount: 123.45,
|
||||||
|
color: "#ef4444",
|
||||||
|
block: "variabel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rueckerstattung",
|
||||||
|
amount: 12,
|
||||||
|
chartAmount: 12,
|
||||||
|
color: "#22c55e",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,24 +3,20 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recha
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatAmount } from "@/lib/format";
|
import { formatAmount } from "@/lib/format";
|
||||||
|
import { type CategoryBreakdownItem, toCategoryPieData } from "./categoryBreakdownData";
|
||||||
|
|
||||||
type Item = {
|
export function CategoryBreakdownChart({ data }: { data: CategoryBreakdownItem[] }) {
|
||||||
name: string;
|
|
||||||
amount: number;
|
|
||||||
color: string;
|
|
||||||
block?: "wiederkehrend" | "variabel";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CategoryBreakdownChart({ data }: { data: Item[] }) {
|
|
||||||
const [filter, setFilter] = useState<"all" | "wiederkehrend" | "variabel">("all");
|
const [filter, setFilter] = useState<"all" | "wiederkehrend" | "variabel">("all");
|
||||||
const filtered = data.filter((d) => {
|
const filtered = data.filter((d) => {
|
||||||
if (filter === "all") return true;
|
if (filter === "all") return true;
|
||||||
return d.block === filter;
|
return d.block === filter;
|
||||||
});
|
});
|
||||||
|
const pieData = toCategoryPieData(filtered);
|
||||||
|
const total = pieData.reduce((sum, item) => sum + item.chartAmount, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<CardTitle>Ausgaben nach Kategorie</CardTitle>
|
<CardTitle>Ausgaben nach Kategorie</CardTitle>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{(["all", "wiederkehrend", "variabel"] as const).map((f) => (
|
{(["all", "wiederkehrend", "variabel"] as const).map((f) => (
|
||||||
@@ -30,18 +26,56 @@ export function CategoryBreakdownChart({ data }: { data: Item[] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-80">
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{pieData.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Keine Ausgaben im gewählten Zeitraum</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(260px,0.85fr)_minmax(320px,1.15fr)] lg:items-center">
|
||||||
|
<div className="h-72 min-h-72 min-w-0 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%" minWidth={240} minHeight={240}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={filtered} dataKey="amount" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
|
<Pie data={pieData} dataKey="chartAmount" nameKey="name" cx="50%" cy="50%" outerRadius={105}>
|
||||||
{filtered.map((entry) => (
|
{pieData.map((entry) => (
|
||||||
<Cell key={entry.name} fill={entry.color} />
|
<Cell key={entry.name} fill={entry.color} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip formatter={(v) => formatAmount(Number(v ?? 0))} />
|
<Tooltip
|
||||||
<Legend />
|
formatter={(v, _name, item) =>
|
||||||
|
formatAmount(
|
||||||
|
typeof item.payload?.amount === "number" ? item.payload.amount : Number(v ?? 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-72 min-w-0 overflow-y-auto pr-1">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{pieData.map((entry) => {
|
||||||
|
const share = total > 0 ? entry.chartAmount / total : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={entry.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-sm">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="truncate font-medium">{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right tabular-nums">
|
||||||
|
<div className="font-medium">{formatAmount(entry.amount)}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{Math.round(share * 100)}%</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -67,13 +101,21 @@ export function FixedVariableSplit({
|
|||||||
<CardContent className="h-64">
|
<CardContent className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={50} outerRadius={80}>
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
stroke="var(--card)"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
{data.map((entry) => (
|
{data.map((entry) => (
|
||||||
<Cell key={entry.name} fill={entry.color} />
|
<Cell key={entry.name} fill={entry.color} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
|
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
|
||||||
<Legend />
|
<Legend wrapperStyle={{ fontSize: 14 }} iconType="circle" />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { eur } from "@/lib/format";
|
import { eur, formatEurCompact } from "@/lib/format";
|
||||||
|
|
||||||
type Point = { month: string; income: number; expenses: number; balance: number };
|
type Point = { month: string; income: number; expenses: number; balance: number };
|
||||||
|
|
||||||
|
const axisTick = { fontSize: 13, fill: "var(--muted-foreground)" };
|
||||||
|
|
||||||
export function MonthlyTrendChart({ data }: { data: Point[] }) {
|
export function MonthlyTrendChart({ data }: { data: Point[] }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -22,12 +24,25 @@ export function MonthlyTrendChart({ data }: { data: Point[] }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-80">
|
<CardContent className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={data}>
|
<ComposedChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: 8 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
<XAxis dataKey="month" />
|
<XAxis
|
||||||
<YAxis tickFormatter={(v) => eur.format(v)} />
|
dataKey="month"
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "var(--border)" }}
|
||||||
|
height={44}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={formatEurCompact}
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={64}
|
||||||
|
/>
|
||||||
<Tooltip formatter={(v) => eur.format(Number(v ?? 0))} />
|
<Tooltip formatter={(v) => eur.format(Number(v ?? 0))} />
|
||||||
<Legend />
|
<Legend wrapperStyle={{ fontSize: 14, paddingTop: 8 }} />
|
||||||
<Bar dataKey="income" name="Einnahmen" fill="#22c55e" />
|
<Bar dataKey="income" name="Einnahmen" fill="#22c55e" />
|
||||||
<Bar dataKey="expenses" name="Ausgaben" fill="#ef4444" />
|
<Bar dataKey="expenses" name="Ausgaben" fill="#ef4444" />
|
||||||
<Line dataKey="balance" name="Saldo" stroke="#6366f1" strokeWidth={2} />
|
<Line dataKey="balance" name="Saldo" stroke="#6366f1" strokeWidth={2} />
|
||||||
|
|||||||
17
src/components/charts/categoryBreakdownData.ts
Normal file
17
src/components/charts/categoryBreakdownData.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type CategoryBreakdownItem = {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
color: string;
|
||||||
|
block?: "wiederkehrend" | "variabel";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryPieItem = CategoryBreakdownItem & {
|
||||||
|
chartAmount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toCategoryPieData(data: CategoryBreakdownItem[]): CategoryPieItem[] {
|
||||||
|
return data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
chartAmount: Math.abs(item.amount),
|
||||||
|
}));
|
||||||
|
}
|
||||||
88
src/components/chat/ChatHistory.tsx
Normal file
88
src/components/chat/ChatHistory.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { MessageCircle, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ChatHistoryItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: number;
|
||||||
|
messageCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatHistoryProps = {
|
||||||
|
items: ChatHistoryItem[];
|
||||||
|
activeId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ChatHistory({
|
||||||
|
items,
|
||||||
|
activeId,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
}: ChatHistoryProps) {
|
||||||
|
return (
|
||||||
|
<aside className="rounded-xl border bg-card text-card-foreground shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<MessageCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<h2 className="truncate text-sm font-semibold">Chat-Historie</h2>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="icon" onClick={onCreate} title="Neuer Chat">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[64vh] overflow-y-auto p-2">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Noch keine Chats
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-1 rounded-md",
|
||||||
|
item.id === activeId ? "bg-accent" : "hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
className="min-w-0 flex-1 px-2 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="block truncate text-sm font-medium">{item.title}</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{dateFormatter.format(new Date(item.updatedAt))} · {item.messageCount} Nachrichten
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mr-1 h-8 w-8 opacity-70 hover:opacity-100"
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
title="Chat löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/components/layout/CategoryFilter.tsx
Normal file
129
src/components/layout/CategoryFilter.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { Check, ChevronDown, X } from "lucide-react";
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { api } from "../../../convex/_generated/api";
|
||||||
|
import { useFilters } from "@/context/FilterContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const NONE_VALUE = "__none__";
|
||||||
|
|
||||||
|
export function CategoryFilter() {
|
||||||
|
const categories = useQuery(api.categories.list);
|
||||||
|
const { categoryIds, setCategoryIds } = useFilters();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedSet = useMemo(() => new Set(categoryIds), [categoryIds]);
|
||||||
|
const noneSelected = selectedSet.has(NONE_VALUE);
|
||||||
|
|
||||||
|
const toggle = (value: string) => {
|
||||||
|
const next = new Set(categoryIds);
|
||||||
|
if (next.has(value)) {
|
||||||
|
next.delete(value);
|
||||||
|
} else {
|
||||||
|
next.add(value);
|
||||||
|
}
|
||||||
|
setCategoryIds(Array.from(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => setCategoryIds([]);
|
||||||
|
|
||||||
|
const label = useMemo(() => {
|
||||||
|
if (categoryIds.length === 0) return "Alle Kategorien";
|
||||||
|
const names: string[] = [];
|
||||||
|
if (noneSelected) names.push("Ohne Kategorie");
|
||||||
|
categories?.forEach((c) => {
|
||||||
|
if (selectedSet.has(c._id)) names.push(c.name);
|
||||||
|
});
|
||||||
|
if (names.length === 1) return names[0];
|
||||||
|
return `${names.length} Kategorien`;
|
||||||
|
}, [categoryIds.length, noneSelected, categories, selectedSet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<Button variant="outline" className="w-[200px] justify-between px-3">
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="z-50 w-[220px] rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between px-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Kategorien filtern</span>
|
||||||
|
{categoryIds.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" /> Zurücksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-auto">
|
||||||
|
<CategoryItem
|
||||||
|
value={NONE_VALUE}
|
||||||
|
label="Ohne Kategorie"
|
||||||
|
color="#9ca3af"
|
||||||
|
checked={noneSelected}
|
||||||
|
onToggle={() => toggle(NONE_VALUE)}
|
||||||
|
/>
|
||||||
|
{categories?.map((c) => (
|
||||||
|
<CategoryItem
|
||||||
|
key={c._id}
|
||||||
|
value={c._id}
|
||||||
|
label={c.name}
|
||||||
|
color={c.color}
|
||||||
|
checked={selectedSet.has(c._id)}
|
||||||
|
onToggle={() => toggle(c._id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryItem({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={`cat-filter-${value}`}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent",
|
||||||
|
checked && "bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 items-center justify-center rounded border">
|
||||||
|
{checked && <Check className="h-3 w-3" />}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id={`cat-filter-${value}`}
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
/>
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
<span className="flex-1 truncate">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
|
MessageCircle,
|
||||||
Import,
|
Import,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const links = [
|
const links = [
|
||||||
{ to: "/", label: "Übersicht", icon: LayoutDashboard },
|
{ to: "/", label: "Übersicht", icon: LayoutDashboard },
|
||||||
{ to: "/transaktionen", label: "Transaktionen", icon: Wallet },
|
{ to: "/transaktionen", label: "Transaktionen", icon: Wallet },
|
||||||
|
{ to: "/talk", label: "Talk to Savings", icon: MessageCircle },
|
||||||
{ to: "/kategorien", label: "Kategorien", icon: FolderTree },
|
{ to: "/kategorien", label: "Kategorien", icon: FolderTree },
|
||||||
{ to: "/kredite", label: "Kredite", icon: CreditCard },
|
{ to: "/kredite", label: "Kredite", icon: CreditCard },
|
||||||
{ to: "/import", label: "CSV & comdirect", icon: Import },
|
{ to: "/import", label: "CSV & comdirect", icon: Import },
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type FilterContextValue = {
|
|||||||
setCustomRange: (from: string, to: string) => void;
|
setCustomRange: (from: string, to: string) => void;
|
||||||
accountId: string | undefined;
|
accountId: string | undefined;
|
||||||
setAccountId: (id: string | undefined) => void;
|
setAccountId: (id: string | undefined) => void;
|
||||||
|
categoryIds: string[];
|
||||||
|
setCategoryIds: (ids: string[]) => void;
|
||||||
monthBasis: MonthBasis;
|
monthBasis: MonthBasis;
|
||||||
setMonthBasis: (basis: MonthBasis) => void;
|
setMonthBasis: (basis: MonthBasis) => void;
|
||||||
};
|
};
|
||||||
@@ -63,6 +65,7 @@ export function FilterProvider({ children }: { children: ReactNode }) {
|
|||||||
const [customFrom, setCustomFrom] = useState<string>();
|
const [customFrom, setCustomFrom] = useState<string>();
|
||||||
const [customTo, setCustomTo] = useState<string>();
|
const [customTo, setCustomTo] = useState<string>();
|
||||||
const [accountId, setAccountId] = useState<string>();
|
const [accountId, setAccountId] = useState<string>();
|
||||||
|
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||||
const [monthBasis, setMonthBasis] = useState<MonthBasis>("effective");
|
const [monthBasis, setMonthBasis] = useState<MonthBasis>("effective");
|
||||||
|
|
||||||
const { from, to } = useMemo(
|
const { from, to } = useMemo(
|
||||||
@@ -83,10 +86,12 @@ export function FilterProvider({ children }: { children: ReactNode }) {
|
|||||||
},
|
},
|
||||||
accountId,
|
accountId,
|
||||||
setAccountId,
|
setAccountId,
|
||||||
|
categoryIds,
|
||||||
|
setCategoryIds,
|
||||||
monthBasis,
|
monthBasis,
|
||||||
setMonthBasis,
|
setMonthBasis,
|
||||||
}),
|
}),
|
||||||
[preset, from, to, accountId, monthBasis],
|
[preset, from, to, accountId, categoryIds, monthBasis],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
|
return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ export function formatAmount(amount: number): string {
|
|||||||
return eur.format(amount);
|
return eur.format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatEurCompact(value: number): string {
|
||||||
|
const abs = Math.abs(value);
|
||||||
|
const sign = value < 0 ? "-" : "";
|
||||||
|
const suffix = (n: number, digits = 1) =>
|
||||||
|
`${sign}${n.toFixed(digits).replace(".", ",")}`;
|
||||||
|
if (abs >= 1_000_000) return `${suffix(abs / 1_000_000)} Mio. €`;
|
||||||
|
if (abs >= 10_000) return `${suffix(abs / 1000)}k €`;
|
||||||
|
if (abs >= 1000) return `${suffix(abs / 1000, 2)}k €`;
|
||||||
|
return eur.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function amountClass(amount: number): string {
|
export function amountClass(amount: number): string {
|
||||||
if (amount < 0) return "text-red-600 dark:text-red-400";
|
if (amount < 0) return "text-red-600 dark:text-red-400";
|
||||||
if (amount > 0) return "text-emerald-600 dark:text-emerald-400";
|
if (amount > 0) return "text-emerald-600 dark:text-emerald-400";
|
||||||
|
|||||||
265
src/pages/SavingsChatPage.tsx
Normal file
265
src/pages/SavingsChatPage.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||||
|
import { useAction, useQuery } from "convex/react";
|
||||||
|
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
||||||
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||||||
|
import { useFilters } from "@/context/FilterContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type ChatMessage = { role: "user" | "assistant"; content: string };
|
||||||
|
type ChatSession = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "savings-chat-sessions";
|
||||||
|
const initialAssistantMessage: ChatMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.",
|
||||||
|
};
|
||||||
|
const fallbackMessages = [initialAssistantMessage];
|
||||||
|
|
||||||
|
function createSession(): ChatSession {
|
||||||
|
const now = Date.now();
|
||||||
|
const randomId =
|
||||||
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${now}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: randomId,
|
||||||
|
title: "Neuer Chat",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
messages: [initialAssistantMessage],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSessions(): ChatSession[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [createSession()];
|
||||||
|
const parsed = JSON.parse(raw) as ChatSession[];
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return [createSession()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromMessages(messages: ChatMessage[]) {
|
||||||
|
const firstUserMessage = messages.find((message) => message.role === "user")?.content.trim();
|
||||||
|
if (!firstUserMessage) return "Neuer Chat";
|
||||||
|
return firstUserMessage.length > 44 ? `${firstUserMessage.slice(0, 44)}…` : firstUserMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SavingsChatPage() {
|
||||||
|
const { from, to, monthBasis } = useFilters();
|
||||||
|
const accountId = useAccountFilterId();
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [sessions, setSessions] = useState<ChatSession[]>(loadSessions);
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? "");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
|
||||||
|
const messages = activeSession?.messages ?? fallbackMessages;
|
||||||
|
|
||||||
|
const context = useQuery(api.savingsChat.getContext, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
accountId,
|
||||||
|
basis: monthBasis,
|
||||||
|
});
|
||||||
|
const ask = useAction(api.savingsChat.ask);
|
||||||
|
|
||||||
|
const buttonDisabled = isSubmitting || draft.trim().length === 0;
|
||||||
|
|
||||||
|
const formatAmount = (amount: number) =>
|
||||||
|
new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(amount);
|
||||||
|
|
||||||
|
const getContextSummary = () => {
|
||||||
|
if (!context) return "Lade Kontext…";
|
||||||
|
return `${context.totals.transactionCount} Umsätze · Einnahmen ${formatAmount(
|
||||||
|
context.totals.income,
|
||||||
|
)} · Ausgaben ${formatAmount(context.totals.expenses)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
const updateSession = (id: string, nextMessages: ChatMessage[]) => {
|
||||||
|
const now = Date.now();
|
||||||
|
setSessions((prev) =>
|
||||||
|
prev.map((session) =>
|
||||||
|
session.id === id
|
||||||
|
? {
|
||||||
|
...session,
|
||||||
|
title: titleFromMessages(nextMessages),
|
||||||
|
updatedAt: now,
|
||||||
|
messages: nextMessages,
|
||||||
|
}
|
||||||
|
: session,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewChat = () => {
|
||||||
|
const session = createSession();
|
||||||
|
setSessions((prev) => [session, ...prev]);
|
||||||
|
setActiveSessionId(session.id);
|
||||||
|
setDraft("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteChat = (id: string) => {
|
||||||
|
const remaining = sessions.filter((session) => session.id !== id);
|
||||||
|
const nextSessions = remaining.length > 0 ? remaining : [createSession()];
|
||||||
|
setSessions(nextSessions);
|
||||||
|
if (id === activeSessionId) setActiveSessionId(nextSessions[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const historyItems: ChatHistoryItem[] = sessions
|
||||||
|
.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
title: session.title,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
messageCount: session.messages.length,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const content = draft.trim();
|
||||||
|
if (!content || isSubmitting) return;
|
||||||
|
|
||||||
|
const submittedSessionId = activeSessionId;
|
||||||
|
const typedNextMessages: ChatMessage[] = [...messages, { role: "user", content }];
|
||||||
|
updateSession(submittedSessionId, typedNextMessages);
|
||||||
|
setDraft("");
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ask({
|
||||||
|
messages: typedNextMessages,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
accountId,
|
||||||
|
basis: monthBasis,
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSession(submittedSessionId, [
|
||||||
|
...typedNextMessages,
|
||||||
|
{ role: "assistant", content: response.answer },
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Antwort konnte nicht geladen werden.");
|
||||||
|
updateSession(submittedSessionId, [
|
||||||
|
...typedNextMessages,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
|
<ChatHistory
|
||||||
|
items={historyItems}
|
||||||
|
activeId={activeSessionId}
|
||||||
|
onSelect={setActiveSessionId}
|
||||||
|
onCreate={createNewChat}
|
||||||
|
onDelete={deleteChat}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
|
<h1 className="text-lg font-semibold">Talk to your savings account</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Kontext der Auswertung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<p>Zeitraum: {from} bis {to}</p>
|
||||||
|
<p>Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}</p>
|
||||||
|
<p>
|
||||||
|
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
|
||||||
|
{context ? formatAmount(context.totals.balance) : "—"})
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{context ? getContextSummary() : "Lade Kontext…"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<div
|
||||||
|
key={`${message.role}-${index}`}
|
||||||
|
className={`rounded-lg border p-3 ${
|
||||||
|
message.role === "user" ? "bg-muted/50" : "bg-background"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">{message.role}</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Denk mit der KI nach…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<form className="flex gap-2" onSubmit={submit}>
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
placeholder="Welche Auswertung soll ich machen?"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={buttonDisabled}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Senden
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -6,12 +6,15 @@ import {
|
|||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { api } from "../../convex/_generated/api";
|
import { api } from "../../convex/_generated/api";
|
||||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||||
|
import { useFilters } from "@/context/FilterContext";
|
||||||
|
import { CategoryFilter } from "@/components/layout/CategoryFilter";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format";
|
import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format";
|
||||||
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
||||||
@@ -19,16 +22,138 @@ import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type Tx = Doc<"transactions">;
|
type Tx = Doc<"transactions">;
|
||||||
|
type Category = Doc<"categories">;
|
||||||
|
|
||||||
|
/* ── Memoized cell components ────────────────────────────────────── */
|
||||||
|
|
||||||
|
const RowCheckbox = memo(function RowCheckbox({
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: (e?: unknown) => void;
|
||||||
|
}) {
|
||||||
|
return <input type="checkbox" checked={checked} onChange={onToggle} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) {
|
||||||
|
return <>{name ?? "–"}</>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const CategoryCell = memo(function CategoryCell({
|
||||||
|
tx,
|
||||||
|
categories,
|
||||||
|
categoryMap,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
tx: Tx;
|
||||||
|
categories: Category[] | undefined;
|
||||||
|
categoryMap: Map<Id<"categories">, Category>;
|
||||||
|
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const cat = tx.categoryId ? categoryMap.get(tx.categoryId) : null;
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex h-6 items-center rounded px-1.5 text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
{cat ? (
|
||||||
|
<Badge style={{ backgroundColor: cat.color, color: "#fff", border: "none" }}>
|
||||||
|
{cat.name}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Kategorie…</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
defaultOpen
|
||||||
|
value={tx.categoryId ?? "none"}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v !== "none") onUpdate(tx._id, v as Id<"categories">);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-[130px]">
|
||||||
|
<SelectValue placeholder="Kategorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories?.map((c) => (
|
||||||
|
<SelectItem key={c._id} value={c._id}>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: c.color }}
|
||||||
|
/>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const AssignedCell = memo(function AssignedCell({
|
||||||
|
assignedMonth,
|
||||||
|
bookingMonth,
|
||||||
|
}: {
|
||||||
|
assignedMonth: string | undefined;
|
||||||
|
bookingMonth: string | undefined;
|
||||||
|
}) {
|
||||||
|
if (!assignedMonth || assignedMonth === bookingMonth) return null;
|
||||||
|
return <Badge variant="outline">{formatMonth(assignedMonth)}</Badge>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ActionsCell = memo(function ActionsCell({
|
||||||
|
tx,
|
||||||
|
onEdit,
|
||||||
|
onAssign,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
tx: Tx;
|
||||||
|
onEdit: (tx: Tx) => void;
|
||||||
|
onAssign: (tx: Tx) => void;
|
||||||
|
onRemove: (id: Id<"transactions">) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onEdit(tx)}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onAssign(tx)}>
|
||||||
|
Monat…
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onRemove(tx._id)}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Page ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function TransactionsPage() {
|
export function TransactionsPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [type, setType] = useState<"all" | "einnahme" | "ausgabe">("all");
|
const [type, setType] = useState<"all" | "einnahme" | "ausgabe">("all");
|
||||||
const [pendingOnly, setPendingOnly] = useState(false);
|
const [pendingOnly, setPendingOnly] = useState(false);
|
||||||
const [selected, setSelected] = useState<Id<"transactions">[]>([]);
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
const [editTx, setEditTx] = useState<Tx | null>(null);
|
const [editTx, setEditTx] = useState<Tx | null>(null);
|
||||||
const [assignTx, setAssignTx] = useState<Tx | null>(null);
|
const [assignTx, setAssignTx] = useState<Tx | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
const { categoryIds } = useFilters();
|
||||||
|
|
||||||
const categories = useQuery(api.categories.list);
|
const categories = useQuery(api.categories.list);
|
||||||
const accounts = useQuery(api.accounts.list);
|
const accounts = useQuery(api.accounts.list);
|
||||||
const removeTx = useMutation(api.transactions.remove);
|
const removeTx = useMutation(api.transactions.remove);
|
||||||
@@ -41,12 +166,46 @@ export function TransactionsPage() {
|
|||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
type: type === "all" ? undefined : type,
|
type: type === "all" ? undefined : type,
|
||||||
pendingOnly: pendingOnly || undefined,
|
pendingOnly: pendingOnly || undefined,
|
||||||
|
categoryIds:
|
||||||
|
categoryIds.length > 0
|
||||||
|
? (categoryIds.filter((id) => id !== "__none__") as Id<"categories">[])
|
||||||
|
: undefined,
|
||||||
|
withoutCategory: categoryIds.includes("__none__") || undefined,
|
||||||
},
|
},
|
||||||
{ initialNumItems: 50 },
|
{ initialNumItems: 50 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryMap = useMemo(() => new Map(categories?.map((c) => [c._id, c])), [categories]);
|
const categoryMap = useMemo(
|
||||||
const accountMap = useMemo(() => new Map(accounts?.map((a) => [a._id, a.name])), [accounts]);
|
() => new Map(categories?.map((c) => [c._id, c])),
|
||||||
|
[categories],
|
||||||
|
);
|
||||||
|
const accountMap = useMemo(
|
||||||
|
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateCategory = useCallback(
|
||||||
|
(id: Id<"transactions">, categoryId: Id<"categories">) => {
|
||||||
|
void updateTx({ id, categoryId });
|
||||||
|
},
|
||||||
|
[updateTx],
|
||||||
|
);
|
||||||
|
const handleEdit = useCallback((tx: Tx) => setEditTx(tx), []);
|
||||||
|
const handleAssign = useCallback((tx: Tx) => setAssignTx(tx), []);
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
async (id: Id<"transactions">) => {
|
||||||
|
if (confirm("Transaktion löschen?")) {
|
||||||
|
await removeTx({ id });
|
||||||
|
toast.success("Gelöscht");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeTx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedIds = useMemo(
|
||||||
|
() => Object.keys(rowSelection).filter((k) => rowSelection[k]) as Id<"transactions">[],
|
||||||
|
[rowSelection],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Tx>[]>(
|
const columns = useMemo<ColumnDef<Tx>[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -54,16 +213,9 @@ export function TransactionsPage() {
|
|||||||
id: "select",
|
id: "select",
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<RowCheckbox
|
||||||
type="checkbox"
|
checked={row.getIsSelected()}
|
||||||
checked={selected.includes(row.original._id)}
|
onToggle={row.getToggleSelectedHandler()}
|
||||||
onChange={(e) => {
|
|
||||||
setSelected((prev) =>
|
|
||||||
e.target.checked
|
|
||||||
? [...prev, row.original._id]
|
|
||||||
: prev.filter((id) => id !== row.original._id),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -78,36 +230,23 @@ export function TransactionsPage() {
|
|||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
header: "Konto",
|
header: "Konto",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => (
|
||||||
row.original.accountId ? accountMap.get(row.original.accountId) ?? "–" : "–",
|
<AccountCell
|
||||||
|
name={row.original.accountId ? accountMap.get(row.original.accountId) : undefined}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "category",
|
id: "category",
|
||||||
header: "Kategorie",
|
header: "Kategorie",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
return (
|
<CategoryCell
|
||||||
<Select
|
tx={row.original}
|
||||||
value={row.original.categoryId ?? "none"}
|
categories={categories}
|
||||||
onValueChange={async (v) => {
|
categoryMap={categoryMap}
|
||||||
if (v === "none") return;
|
onUpdate={handleUpdateCategory}
|
||||||
await updateTx({ id: row.original._id, categoryId: v as Id<"categories"> });
|
/>
|
||||||
}}
|
),
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[180px]">
|
|
||||||
<SelectValue placeholder="Kategorie" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{categories?.map((c) => (
|
|
||||||
<SelectItem key={c._id} value={c._id}>
|
|
||||||
<Badge style={{ backgroundColor: c.color, color: "#fff", border: "none" }}>
|
|
||||||
{c.name}
|
|
||||||
</Badge>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "amount",
|
accessorKey: "amount",
|
||||||
@@ -123,44 +262,72 @@ export function TransactionsPage() {
|
|||||||
header: "Zuordnung",
|
header: "Zuordnung",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const bookingMonth = row.original.bookingDate?.slice(0, 7);
|
const bookingMonth = row.original.bookingDate?.slice(0, 7);
|
||||||
if (!row.original.assignedMonth || row.original.assignedMonth === bookingMonth) return null;
|
return (
|
||||||
return <Badge variant="outline">{formatMonth(row.original.assignedMonth)}</Badge>;
|
<AssignedCell assignedMonth={row.original.assignedMonth} bookingMonth={bookingMonth} />
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "",
|
header: "",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-1">
|
<ActionsCell
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditTx(row.original)}>
|
tx={row.original}
|
||||||
Bearbeiten
|
onEdit={handleEdit}
|
||||||
</Button>
|
onAssign={handleAssign}
|
||||||
<Button size="sm" variant="ghost" onClick={() => setAssignTx(row.original)}>
|
onRemove={handleRemove}
|
||||||
Monat…
|
/>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={async () => {
|
|
||||||
if (confirm("Transaktion löschen?")) {
|
|
||||||
await removeTx({ id: row.original._id });
|
|
||||||
toast.success("Gelöscht");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[categories, categoryMap, accountMap, selected, updateTx, removeTx],
|
[
|
||||||
|
categories,
|
||||||
|
categoryMap,
|
||||||
|
accountMap,
|
||||||
|
handleUpdateCategory,
|
||||||
|
handleEdit,
|
||||||
|
handleAssign,
|
||||||
|
handleRemove,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({ data: results ?? [], columns, getCoreRowModel: getCoreRowModel() });
|
const table = useReactTable({
|
||||||
|
data: results ?? [],
|
||||||
|
columns,
|
||||||
|
state: { rowSelection },
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getRowId: (row) => row._id,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Virtualization ── */
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rows = table.getRowModel().rows;
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 44,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||||
|
const paddingBottom =
|
||||||
|
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
|
||||||
|
|
||||||
|
/* ── Auto-load more when scrolling near the end ── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "CanLoadMore") return;
|
||||||
|
const last = virtualRows[virtualRows.length - 1];
|
||||||
|
if (last && last.index >= rows.length - 10) {
|
||||||
|
loadMore(50);
|
||||||
|
}
|
||||||
|
}, [virtualRows, rows.length, status, loadMore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="flex h-[calc(100vh-8rem)] min-h-0 flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Suche…"
|
placeholder="Suche…"
|
||||||
@@ -178,17 +345,25 @@ export function TransactionsPage() {
|
|||||||
<SelectItem value="ausgabe">Ausgaben</SelectItem>
|
<SelectItem value="ausgabe">Ausgaben</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<CategoryFilter />
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={pendingOnly} onChange={(e) => setPendingOnly(e.target.checked)} />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pendingOnly}
|
||||||
|
onChange={(e) => setPendingOnly(e.target.checked)}
|
||||||
|
/>
|
||||||
Nur offene
|
Nur offene
|
||||||
</label>
|
</label>
|
||||||
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
|
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
|
||||||
{selected.length > 0 && categories && (
|
{selectedIds.length > 0 && categories && (
|
||||||
<Select
|
<Select
|
||||||
onValueChange={async (categoryId) => {
|
onValueChange={async (categoryId) => {
|
||||||
await bulkCategory({ ids: selected, categoryId: categoryId as Id<"categories"> });
|
await bulkCategory({
|
||||||
|
ids: selectedIds,
|
||||||
|
categoryId: categoryId as Id<"categories">,
|
||||||
|
});
|
||||||
toast.success("Kategorie zugewiesen");
|
toast.success("Kategorie zugewiesen");
|
||||||
setSelected([]);
|
setRowSelection({});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectTrigger className="w-[200px]">
|
||||||
@@ -205,37 +380,53 @@ export function TransactionsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div className="min-h-0 flex-1 rounded-md border">
|
||||||
<Table>
|
<div ref={parentRef} className="relative h-full w-full overflow-auto">
|
||||||
|
<table className="w-full caption-bottom text-sm">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((hg) => (
|
{table.getHeaderGroups().map((hg) => (
|
||||||
<TableRow key={hg.id}>
|
<TableRow key={hg.id}>
|
||||||
{hg.headers.map((h) => (
|
{hg.headers.map((h) => (
|
||||||
<TableHead key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</TableHead>
|
<TableHead key={h.id} className="sticky top-0 z-10 bg-background">
|
||||||
|
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||||
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{paddingTop > 0 && <tr style={{ height: paddingTop }} />}
|
||||||
<TableRow key={row.id}>
|
{virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={(node: HTMLTableRowElement | null) =>
|
||||||
|
rowVirtualizer.measureElement(node)
|
||||||
|
}
|
||||||
|
className="border-b transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
<TableCell key={cell.id}>
|
||||||
))}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableRow>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{paddingBottom > 0 && <tr style={{ height: paddingBottom }} />}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "CanLoadMore" && (
|
|
||||||
<Button variant="outline" onClick={() => loadMore(50)}>
|
|
||||||
Mehr laden
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TransactionFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
<TransactionFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
<TransactionFormDialog open={!!editTx} onOpenChange={(o) => !o && setEditTx(null)} transaction={editTx ?? undefined} />
|
<TransactionFormDialog
|
||||||
|
open={!!editTx}
|
||||||
|
onOpenChange={(o) => !o && setEditTx(null)}
|
||||||
|
transaction={editTx ?? undefined}
|
||||||
|
/>
|
||||||
<AssignMonthDialog transaction={assignTx} onClose={() => setAssignTx(null)} />
|
<AssignMonthDialog transaction={assignTx} onClose={() => setAssignTx(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
19
vitest.config.ts
Normal file
19
vitest.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"@convex-lib": path.resolve(__dirname, "./convex/lib"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "edge-runtime",
|
||||||
|
server: {
|
||||||
|
deps: {
|
||||||
|
inline: ["convex-test"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user