chore: archive finance, mail, moodlit apps and rename voxel-lava

- Move finance, mail, moodlit to apps-archived for later development
- Rename games/voxel-lava to games/voxelava

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-05 13:13:15 +01:00
parent c3c272abc9
commit ace7fa8f7f
427 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,180 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
:root {
/* Finance App - Green/Emerald Theme */
--color-primary: #10b981;
--color-primary-hover: #059669;
--color-primary-light: #34d399;
--color-primary-dark: #047857;
--color-secondary: #ecfdf5;
--color-secondary-hover: #d1fae5;
--color-accent: #6ee7b7;
--color-accent-hover: #34d399;
/* Transaction types */
--color-income: #22c55e;
--color-income-bg: #dcfce7;
--color-expense: #ef4444;
--color-expense-bg: #fee2e2;
--color-transfer: #3b82f6;
--color-transfer-bg: #dbeafe;
/* Budget status */
--color-budget-ok: #22c55e;
--color-budget-warning: #eab308;
--color-budget-danger: #ef4444;
--color-budget-over: #dc2626;
/* Account types */
--color-checking: #3b82f6;
--color-savings: #22c55e;
--color-credit-card: #f97316;
--color-cash: #8b5cf6;
--color-investment: #06b6d4;
--color-loan: #ef4444;
}
/* Dark mode overrides */
:root.dark {
--color-secondary: #064e3b;
--color-secondary-hover: #065f46;
--color-income-bg: #14532d;
--color-expense-bg: #7f1d1d;
--color-transfer-bg: #1e3a8a;
}
/* Transaction item styling */
.transaction-item {
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.transaction-item:hover {
transform: translateY(-1px);
}
/* Amount styling */
.amount-income {
color: var(--color-income);
}
.amount-expense {
color: var(--color-expense);
}
.amount-transfer {
color: var(--color-transfer);
}
/* Budget progress bar */
.budget-progress {
height: 8px;
border-radius: 4px;
background-color: var(--color-secondary);
overflow: hidden;
}
.budget-progress-bar {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.budget-ok .budget-progress-bar {
background-color: var(--color-budget-ok);
}
.budget-warning .budget-progress-bar {
background-color: var(--color-budget-warning);
}
.budget-danger .budget-progress-bar {
background-color: var(--color-budget-danger);
}
.budget-over .budget-progress-bar {
background-color: var(--color-budget-over);
}
/* Account card */
.account-card {
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.account-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Category chip */
.category-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
/* Chart container */
.chart-container {
position: relative;
width: 100%;
height: 300px;
}
/* Currency input */
.currency-input {
font-variant-numeric: tabular-nums;
}
/* Date range picker */
.date-range-picker {
display: flex;
gap: 8px;
align-items: center;
}
/* Quick stats */
.stat-card {
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.stat-card:hover {
transform: translateY(-1px);
}
/* Filter chips */
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 9999px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
}
.filter-chip:hover {
background-color: var(--color-secondary-hover);
}
.filter-chip.active {
background-color: var(--color-primary);
color: white;
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
import { apiClient } from './client';
import type { Account, CreateAccountInput, UpdateAccountInput } from '@finance/shared';
export const accountsApi = {
getAll: () => apiClient.get<Account[]>('/accounts'),
getAllIncludingArchived: () => apiClient.get<Account[]>('/accounts/all'),
getOne: (id: string) => apiClient.get<Account>(`/accounts/${id}`),
getTotals: () =>
apiClient.get<{ currency: string; total: number; count: number }[]>('/accounts/totals'),
create: (data: CreateAccountInput) => apiClient.post<Account>('/accounts', data),
update: (id: string, data: UpdateAccountInput) => apiClient.put<Account>(`/accounts/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/accounts/${id}`),
archive: (id: string) => apiClient.post<Account>(`/accounts/${id}/archive`),
unarchive: (id: string) => apiClient.post<Account>(`/accounts/${id}/unarchive`),
reorder: (accountIds: string[]) => apiClient.put<Account[]>('/accounts/reorder', { accountIds }),
};

View file

@ -0,0 +1,43 @@
import { apiClient } from './client';
import type { Budget, CreateBudgetInput, UpdateBudgetInput } from '@finance/shared';
// Budget with computed spending fields from API
export interface BudgetWithSpending {
id: string;
userId: string;
categoryId: string | null;
month: number;
year: number;
amount: string;
alertThreshold: string;
rolloverEnabled: boolean;
createdAt: Date;
updatedAt: Date;
spent: number;
remaining: number;
percentage: number;
category?: {
id: string;
name: string;
color: string;
icon?: string;
} | null;
}
export const budgetsApi = {
getAll: () => apiClient.get<Budget[]>('/budgets'),
getByMonth: (year: number, month: number) =>
apiClient.get<BudgetWithSpending[]>(`/budgets/month/${year}/${month}`),
getOne: (id: string) => apiClient.get<Budget>(`/budgets/${id}`),
create: (data: CreateBudgetInput) => apiClient.post<Budget>('/budgets', data),
update: (id: string, data: UpdateBudgetInput) => apiClient.put<Budget>(`/budgets/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/budgets/${id}`),
copyFromPreviousMonth: (year: number, month: number) =>
apiClient.post<{ message: string; copied: number }>('/budgets/copy', { year, month }),
};

View file

@ -0,0 +1,30 @@
import { apiClient } from './client';
import type {
Category,
CreateCategoryInput,
UpdateCategoryInput,
CategoryType,
} from '@finance/shared';
export const categoriesApi = {
getAll: (type?: CategoryType) => {
const params = type ? `?type=${type}` : '';
return apiClient.get<Category[]>(`/categories${params}`);
},
getAllIncludingArchived: () => apiClient.get<Category[]>('/categories/all'),
getTree: () => apiClient.get<(Category & { children: Category[] })[]>('/categories/tree'),
getOne: (id: string) => apiClient.get<Category>(`/categories/${id}`),
create: (data: CreateCategoryInput) => apiClient.post<Category>('/categories', data),
update: (id: string, data: UpdateCategoryInput) =>
apiClient.put<Category>(`/categories/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/categories/${id}`),
seed: () =>
apiClient.post<{ message: string; seeded: boolean; count?: number }>('/categories/seed'),
};

View file

@ -0,0 +1,61 @@
import { PUBLIC_BACKEND_URL } from '$env/static/public';
class ApiClient {
private baseUrl: string;
private token: string | null = null;
constructor() {
this.baseUrl = PUBLIC_BACKEND_URL || 'http://localhost:3019';
}
setToken(token: string | null) {
this.token = token;
}
private async request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestInit
): Promise<T> {
const url = `${this.baseUrl}/api/v1${path}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(this.token && { Authorization: `Bearer ${this.token}` }),
...options?.headers,
};
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
...options,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
get<T>(path: string, options?: RequestInit): Promise<T> {
return this.request<T>('GET', path, undefined, options);
}
post<T>(path: string, body?: unknown, options?: RequestInit): Promise<T> {
return this.request<T>('POST', path, body, options);
}
put<T>(path: string, body?: unknown, options?: RequestInit): Promise<T> {
return this.request<T>('PUT', path, body, options);
}
delete<T>(path: string, options?: RequestInit): Promise<T> {
return this.request<T>('DELETE', path, undefined, options);
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,25 @@
import { apiClient } from './client';
interface ExchangeRate {
fromCurrency: string;
toCurrency: string;
rate: number;
date: string;
}
export const exchangeRatesApi = {
getAll: (baseCurrency = 'EUR') =>
apiClient.get<ExchangeRate[]>(`/exchange-rates?base=${baseCurrency}`),
getRate: (fromCurrency: string, toCurrency: string) =>
apiClient.get<number>(`/exchange-rates/rate?from=${fromCurrency}&to=${toCurrency}`),
convert: (amount: number, fromCurrency: string, toCurrency: string) =>
apiClient.get<number>(
`/exchange-rates/convert?amount=${amount}&from=${fromCurrency}&to=${toCurrency}`
),
seed: () => apiClient.post<{ message: string; seeded: boolean }>('/exchange-rates/seed'),
fetch: () => apiClient.post<void>('/exchange-rates/fetch'),
};

View file

@ -0,0 +1,9 @@
export { apiClient } from './client';
export { accountsApi } from './accounts';
export { categoriesApi } from './categories';
export { transactionsApi } from './transactions';
export { budgetsApi } from './budgets';
export { transfersApi } from './transfers';
export { reportsApi } from './reports';
export { settingsApi } from './settings';
export { exchangeRatesApi } from './exchange-rates';

View file

@ -0,0 +1,89 @@
import { apiClient } from './client';
import type { DashboardData, MonthlySummary, CategoryBreakdown, TrendData } from '@finance/shared';
interface Dashboard {
totals: { currency: string; amount: number }[];
currentMonth: {
year: number;
month: number;
income: number;
expense: number;
net: number;
};
budgets: {
id: string;
category: { id: string; name: string; color: string } | null;
amount: number;
spent: number;
percentage: number;
}[];
recentTransactions: unknown[];
}
interface CategoryBreakdownResponse {
startDate: string;
endDate: string;
type: string;
total: number;
categories: {
categoryId: string | null;
name: string;
color: string | null;
icon: string | null;
amount: number;
count: number;
percentage: number;
}[];
}
interface TrendsResponse {
months: number;
data: {
year: number;
month: number;
income: number;
expense: number;
net: number;
}[];
averages: {
income: number;
expense: number;
net: number;
};
}
interface CashFlowResponse {
startDate: string;
endDate: string;
startingBalance: number;
endingBalance: number;
data: {
date: string;
balance: number;
income: number;
expense: number;
}[];
}
export const reportsApi = {
getDashboard: () => apiClient.get<Dashboard>('/reports/dashboard'),
getMonthlySummary: (year?: number, month?: number) => {
const params = new URLSearchParams();
if (year) params.append('year', String(year));
if (month) params.append('month', String(month));
const query = params.toString();
return apiClient.get<MonthlySummary>(`/reports/monthly-summary${query ? `?${query}` : ''}`);
},
getCategoryBreakdown: (startDate: string, endDate: string, type?: 'income' | 'expense') => {
const params = new URLSearchParams({ startDate, endDate });
if (type) params.append('type', type);
return apiClient.get<CategoryBreakdownResponse>(`/reports/category-breakdown?${params}`);
},
getTrends: (months = 6) => apiClient.get<TrendsResponse>(`/reports/trends?months=${months}`),
getCashFlow: (startDate: string, endDate: string) =>
apiClient.get<CashFlowResponse>(`/reports/cash-flow?startDate=${startDate}&endDate=${endDate}`),
};

View file

@ -0,0 +1,8 @@
import { apiClient } from './client';
import type { UserSettings, UpdateUserSettingsInput } from '@finance/shared';
export const settingsApi = {
get: () => apiClient.get<UserSettings>('/settings'),
update: (data: UpdateUserSettingsInput) => apiClient.put<UserSettings>('/settings', data),
};

View file

@ -0,0 +1,49 @@
import { apiClient } from './client';
import type {
Transaction,
CreateTransactionInput,
UpdateTransactionInput,
TransactionFilters,
} from '@finance/shared';
interface PaginatedTransactions {
data: Transaction[];
total: number;
limit: number;
offset: number;
}
export const transactionsApi = {
getAll: (filters?: TransactionFilters) => {
const params = new URLSearchParams();
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, String(value));
}
});
}
const query = params.toString();
return apiClient.get<PaginatedTransactions>(`/transactions${query ? `?${query}` : ''}`);
},
getRecent: (limit = 10) => apiClient.get<Transaction[]>(`/transactions/recent?limit=${limit}`),
getSummary: (startDate: string, endDate: string) =>
apiClient.get<{
income: number;
expense: number;
net: number;
incomeCount: number;
expenseCount: number;
}>(`/transactions/summary?startDate=${startDate}&endDate=${endDate}`),
getOne: (id: string) => apiClient.get<Transaction>(`/transactions/${id}`),
create: (data: CreateTransactionInput) => apiClient.post<Transaction>('/transactions', data),
update: (id: string, data: UpdateTransactionInput) =>
apiClient.put<Transaction>(`/transactions/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/transactions/${id}`),
};

View file

@ -0,0 +1,15 @@
import { apiClient } from './client';
import type { Transfer, CreateTransferInput, UpdateTransferInput } from '@finance/shared';
export const transfersApi = {
getAll: () => apiClient.get<Transfer[]>('/transfers'),
getOne: (id: string) => apiClient.get<Transfer>(`/transfers/${id}`),
create: (data: CreateTransferInput) => apiClient.post<Transfer>('/transfers', data),
update: (id: string, data: UpdateTransferInput) =>
apiClient.put<Transfer>(`/transfers/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/transfers/${id}`),
};

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { MANA_APPS, getActiveManaApps } from '@manacore/shared-branding';
let { isOpen = $bindable(false) } = $props();
// Get only active (non-archived) apps
const apps = getActiveManaApps();
function close() {
isOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
close();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<button
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onclick={close}
aria-label="Close app menu"
tabindex="-1"
></button>
<!-- Slider -->
<div class="fixed left-0 top-0 z-50 h-full w-80 bg-card shadow-xl overflow-y-auto">
<div class="p-4">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold">ManaCore Apps</h2>
<button onclick={close} class="rounded-lg p-2 hover:bg-accent" aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="grid grid-cols-3 gap-3">
{#each apps as app}
<a
href={app.url || '#'}
class="flex flex-col items-center gap-2 rounded-lg p-3 hover:bg-accent transition-colors {app.comingSoon
? 'opacity-50'
: ''}"
target="_blank"
rel="noopener noreferrer"
>
<div
class="h-12 w-12 rounded-xl flex items-center justify-center overflow-hidden"
style="background-color: {app.color}20;"
>
<img src={app.icon} alt={app.name} class="h-8 w-8" />
</div>
<span class="text-xs text-center font-medium">{app.name}</span>
</a>
{/each}
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,53 @@
<script lang="ts">
let isOpen = $state(false);
let currentLang = $state('de');
const languages = [
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
];
function selectLanguage(code: string) {
currentLang = code;
isOpen = false;
// TODO: Implement language switching
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.language-selector')) {
isOpen = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="language-selector relative">
<button
onclick={() => (isOpen = !isOpen)}
class="flex items-center gap-1 rounded-lg px-2 py-1 hover:bg-accent"
aria-label="Select language"
>
<span class="text-lg">{languages.find((l) => l.code === currentLang)?.flag}</span>
</button>
{#if isOpen}
<div
class="absolute right-0 top-full mt-1 z-50 rounded-lg border border-border bg-card shadow-lg"
>
{#each languages as lang}
<button
onclick={() => selectLanguage(lang.code)}
class="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-accent {currentLang ===
lang.code
? 'bg-accent'
: ''}"
>
<span class="text-lg">{lang.flag}</span>
<span class="text-sm">{lang.label}</span>
</button>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,58 @@
/**
* i18n setup for Finance app
* Supports: DE, EN, FR, ES, IT
*/
import { browser } from '$app/environment';
import { init, register, locale, getLocaleFromNavigator } from 'svelte-i18n';
// Supported locales
export const supportedLocales = ['de', 'en', 'fr', 'es', 'it'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Register locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
register('fr', () => import('./locales/fr.json'));
register('es', () => import('./locales/es.json'));
register('it', () => import('./locales/it.json'));
// Get initial locale
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const saved = localStorage.getItem('finance-locale');
if (saved && supportedLocales.includes(saved as SupportedLocale)) {
return saved as SupportedLocale;
}
// Fall back to browser language
const browserLocale = getLocaleFromNavigator();
if (browserLocale) {
const shortLocale = browserLocale.split('-')[0] as SupportedLocale;
if (supportedLocales.includes(shortLocale)) {
return shortLocale;
}
}
}
// Default to German
return 'de';
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: 'de',
initialLocale: getInitialLocale(),
});
// Set locale and persist
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('finance-locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale } from 'svelte-i18n';

View file

@ -0,0 +1,133 @@
{
"app": {
"name": "Finance",
"loading": "Laden..."
},
"nav": {
"dashboard": "Übersicht",
"accounts": "Konten",
"transactions": "Transaktionen",
"budgets": "Budgets",
"categories": "Kategorien",
"reports": "Berichte",
"settings": "Einstellungen",
"feedback": "Feedback"
},
"auth": {
"login": "Anmelden",
"register": "Registrieren",
"logout": "Abmelden",
"forgotPassword": "Passwort vergessen",
"email": "E-Mail",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen"
},
"dashboard": {
"title": "Finanzübersicht",
"totalBalance": "Gesamtguthaben",
"income": "Einnahmen",
"expenses": "Ausgaben",
"savings": "Ersparnis",
"recentTransactions": "Letzte Transaktionen",
"budgetOverview": "Budget-Übersicht"
},
"accounts": {
"title": "Konten",
"add": "Konto hinzufügen",
"edit": "Konto bearbeiten",
"delete": "Konto löschen",
"name": "Kontoname",
"type": "Kontotyp",
"balance": "Kontostand",
"currency": "Währung",
"noAccounts": "Keine Konten vorhanden",
"types": {
"checking": "Girokonto",
"savings": "Sparkonto",
"credit": "Kreditkarte",
"cash": "Bargeld",
"investment": "Investment"
}
},
"transactions": {
"title": "Transaktionen",
"add": "Transaktion hinzufügen",
"edit": "Transaktion bearbeiten",
"delete": "Transaktion löschen",
"amount": "Betrag",
"date": "Datum",
"description": "Beschreibung",
"category": "Kategorie",
"account": "Konto",
"type": "Art",
"noTransactions": "Keine Transaktionen vorhanden",
"types": {
"income": "Einnahme",
"expense": "Ausgabe",
"transfer": "Überweisung"
}
},
"budgets": {
"title": "Budgets",
"add": "Budget hinzufügen",
"edit": "Budget bearbeiten",
"delete": "Budget löschen",
"name": "Budgetname",
"amount": "Betrag",
"spent": "Ausgegeben",
"remaining": "Verbleibend",
"period": "Zeitraum",
"category": "Kategorie",
"noBudgets": "Keine Budgets vorhanden",
"periods": {
"weekly": "Wöchentlich",
"monthly": "Monatlich",
"yearly": "Jährlich"
}
},
"categories": {
"title": "Kategorien",
"add": "Kategorie hinzufügen",
"edit": "Kategorie bearbeiten",
"delete": "Kategorie löschen",
"name": "Name",
"icon": "Symbol",
"color": "Farbe",
"noCategories": "Keine Kategorien vorhanden"
},
"reports": {
"title": "Berichte",
"incomeVsExpenses": "Einnahmen vs. Ausgaben",
"categoryBreakdown": "Aufschlüsselung nach Kategorien",
"trends": "Trends",
"export": "Exportieren"
},
"settings": {
"title": "Einstellungen",
"general": "Allgemein",
"appearance": "Darstellung",
"currency": "Standardwährung",
"language": "Sprache",
"theme": "Design",
"darkMode": "Dunkelmodus",
"notifications": "Benachrichtigungen"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"confirm": "Bestätigen",
"yes": "Ja",
"no": "Nein",
"ok": "OK",
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg",
"back": "Zurück",
"search": "Suchen",
"filter": "Filtern",
"sort": "Sortieren"
}
}

View file

@ -0,0 +1,133 @@
{
"app": {
"name": "Finance",
"loading": "Loading..."
},
"nav": {
"dashboard": "Dashboard",
"accounts": "Accounts",
"transactions": "Transactions",
"budgets": "Budgets",
"categories": "Categories",
"reports": "Reports",
"settings": "Settings",
"feedback": "Feedback"
},
"auth": {
"login": "Sign In",
"register": "Sign Up",
"logout": "Sign Out",
"forgotPassword": "Forgot Password",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password"
},
"dashboard": {
"title": "Financial Overview",
"totalBalance": "Total Balance",
"income": "Income",
"expenses": "Expenses",
"savings": "Savings",
"recentTransactions": "Recent Transactions",
"budgetOverview": "Budget Overview"
},
"accounts": {
"title": "Accounts",
"add": "Add Account",
"edit": "Edit Account",
"delete": "Delete Account",
"name": "Account Name",
"type": "Account Type",
"balance": "Balance",
"currency": "Currency",
"noAccounts": "No accounts yet",
"types": {
"checking": "Checking",
"savings": "Savings",
"credit": "Credit Card",
"cash": "Cash",
"investment": "Investment"
}
},
"transactions": {
"title": "Transactions",
"add": "Add Transaction",
"edit": "Edit Transaction",
"delete": "Delete Transaction",
"amount": "Amount",
"date": "Date",
"description": "Description",
"category": "Category",
"account": "Account",
"type": "Type",
"noTransactions": "No transactions yet",
"types": {
"income": "Income",
"expense": "Expense",
"transfer": "Transfer"
}
},
"budgets": {
"title": "Budgets",
"add": "Add Budget",
"edit": "Edit Budget",
"delete": "Delete Budget",
"name": "Budget Name",
"amount": "Amount",
"spent": "Spent",
"remaining": "Remaining",
"period": "Period",
"category": "Category",
"noBudgets": "No budgets yet",
"periods": {
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly"
}
},
"categories": {
"title": "Categories",
"add": "Add Category",
"edit": "Edit Category",
"delete": "Delete Category",
"name": "Name",
"icon": "Icon",
"color": "Color",
"noCategories": "No categories yet"
},
"reports": {
"title": "Reports",
"incomeVsExpenses": "Income vs. Expenses",
"categoryBreakdown": "Category Breakdown",
"trends": "Trends",
"export": "Export"
},
"settings": {
"title": "Settings",
"general": "General",
"appearance": "Appearance",
"currency": "Default Currency",
"language": "Language",
"theme": "Theme",
"darkMode": "Dark Mode",
"notifications": "Notifications"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"ok": "OK",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"back": "Back",
"search": "Search",
"filter": "Filter",
"sort": "Sort"
}
}

View file

@ -0,0 +1,133 @@
{
"app": {
"name": "Finance",
"loading": "Cargando..."
},
"nav": {
"dashboard": "Panel",
"accounts": "Cuentas",
"transactions": "Transacciones",
"budgets": "Presupuestos",
"categories": "Categorías",
"reports": "Informes",
"settings": "Configuración",
"feedback": "Feedback"
},
"auth": {
"login": "Iniciar sesión",
"register": "Registrarse",
"logout": "Cerrar sesión",
"forgotPassword": "Olvidé mi contraseña",
"email": "Correo electrónico",
"password": "Contraseña",
"confirmPassword": "Confirmar contraseña"
},
"dashboard": {
"title": "Resumen financiero",
"totalBalance": "Saldo total",
"income": "Ingresos",
"expenses": "Gastos",
"savings": "Ahorros",
"recentTransactions": "Transacciones recientes",
"budgetOverview": "Resumen de presupuesto"
},
"accounts": {
"title": "Cuentas",
"add": "Añadir cuenta",
"edit": "Editar cuenta",
"delete": "Eliminar cuenta",
"name": "Nombre de cuenta",
"type": "Tipo de cuenta",
"balance": "Saldo",
"currency": "Moneda",
"noAccounts": "Sin cuentas",
"types": {
"checking": "Cuenta corriente",
"savings": "Cuenta de ahorro",
"credit": "Tarjeta de crédito",
"cash": "Efectivo",
"investment": "Inversión"
}
},
"transactions": {
"title": "Transacciones",
"add": "Añadir transacción",
"edit": "Editar transacción",
"delete": "Eliminar transacción",
"amount": "Importe",
"date": "Fecha",
"description": "Descripción",
"category": "Categoría",
"account": "Cuenta",
"type": "Tipo",
"noTransactions": "Sin transacciones",
"types": {
"income": "Ingreso",
"expense": "Gasto",
"transfer": "Transferencia"
}
},
"budgets": {
"title": "Presupuestos",
"add": "Añadir presupuesto",
"edit": "Editar presupuesto",
"delete": "Eliminar presupuesto",
"name": "Nombre del presupuesto",
"amount": "Importe",
"spent": "Gastado",
"remaining": "Restante",
"period": "Período",
"category": "Categoría",
"noBudgets": "Sin presupuestos",
"periods": {
"weekly": "Semanal",
"monthly": "Mensual",
"yearly": "Anual"
}
},
"categories": {
"title": "Categorías",
"add": "Añadir categoría",
"edit": "Editar categoría",
"delete": "Eliminar categoría",
"name": "Nombre",
"icon": "Icono",
"color": "Color",
"noCategories": "Sin categorías"
},
"reports": {
"title": "Informes",
"incomeVsExpenses": "Ingresos vs. Gastos",
"categoryBreakdown": "Desglose por categoría",
"trends": "Tendencias",
"export": "Exportar"
},
"settings": {
"title": "Configuración",
"general": "General",
"appearance": "Apariencia",
"currency": "Moneda predeterminada",
"language": "Idioma",
"theme": "Tema",
"darkMode": "Modo oscuro",
"notifications": "Notificaciones"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"add": "Añadir",
"confirm": "Confirmar",
"yes": "Sí",
"no": "No",
"ok": "OK",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
"back": "Atrás",
"search": "Buscar",
"filter": "Filtrar",
"sort": "Ordenar"
}
}

View file

@ -0,0 +1,133 @@
{
"app": {
"name": "Finance",
"loading": "Chargement..."
},
"nav": {
"dashboard": "Tableau de bord",
"accounts": "Comptes",
"transactions": "Transactions",
"budgets": "Budgets",
"categories": "Catégories",
"reports": "Rapports",
"settings": "Paramètres",
"feedback": "Feedback"
},
"auth": {
"login": "Se connecter",
"register": "S'inscrire",
"logout": "Se déconnecter",
"forgotPassword": "Mot de passe oublié",
"email": "E-mail",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe"
},
"dashboard": {
"title": "Aperçu financier",
"totalBalance": "Solde total",
"income": "Revenus",
"expenses": "Dépenses",
"savings": "Épargne",
"recentTransactions": "Transactions récentes",
"budgetOverview": "Aperçu du budget"
},
"accounts": {
"title": "Comptes",
"add": "Ajouter un compte",
"edit": "Modifier le compte",
"delete": "Supprimer le compte",
"name": "Nom du compte",
"type": "Type de compte",
"balance": "Solde",
"currency": "Devise",
"noAccounts": "Aucun compte",
"types": {
"checking": "Compte courant",
"savings": "Compte épargne",
"credit": "Carte de crédit",
"cash": "Espèces",
"investment": "Investissement"
}
},
"transactions": {
"title": "Transactions",
"add": "Ajouter une transaction",
"edit": "Modifier la transaction",
"delete": "Supprimer la transaction",
"amount": "Montant",
"date": "Date",
"description": "Description",
"category": "Catégorie",
"account": "Compte",
"type": "Type",
"noTransactions": "Aucune transaction",
"types": {
"income": "Revenu",
"expense": "Dépense",
"transfer": "Virement"
}
},
"budgets": {
"title": "Budgets",
"add": "Ajouter un budget",
"edit": "Modifier le budget",
"delete": "Supprimer le budget",
"name": "Nom du budget",
"amount": "Montant",
"spent": "Dépensé",
"remaining": "Restant",
"period": "Période",
"category": "Catégorie",
"noBudgets": "Aucun budget",
"periods": {
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"yearly": "Annuel"
}
},
"categories": {
"title": "Catégories",
"add": "Ajouter une catégorie",
"edit": "Modifier la catégorie",
"delete": "Supprimer la catégorie",
"name": "Nom",
"icon": "Icône",
"color": "Couleur",
"noCategories": "Aucune catégorie"
},
"reports": {
"title": "Rapports",
"incomeVsExpenses": "Revenus vs. Dépenses",
"categoryBreakdown": "Répartition par catégorie",
"trends": "Tendances",
"export": "Exporter"
},
"settings": {
"title": "Paramètres",
"general": "Général",
"appearance": "Apparence",
"currency": "Devise par défaut",
"language": "Langue",
"theme": "Thème",
"darkMode": "Mode sombre",
"notifications": "Notifications"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"add": "Ajouter",
"confirm": "Confirmer",
"yes": "Oui",
"no": "Non",
"ok": "OK",
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès",
"back": "Retour",
"search": "Rechercher",
"filter": "Filtrer",
"sort": "Trier"
}
}

View file

@ -0,0 +1,133 @@
{
"app": {
"name": "Finance",
"loading": "Caricamento..."
},
"nav": {
"dashboard": "Panoramica",
"accounts": "Conti",
"transactions": "Transazioni",
"budgets": "Budget",
"categories": "Categorie",
"reports": "Report",
"settings": "Impostazioni",
"feedback": "Feedback"
},
"auth": {
"login": "Accedi",
"register": "Registrati",
"logout": "Esci",
"forgotPassword": "Password dimenticata",
"email": "E-mail",
"password": "Password",
"confirmPassword": "Conferma password"
},
"dashboard": {
"title": "Panoramica finanziaria",
"totalBalance": "Saldo totale",
"income": "Entrate",
"expenses": "Spese",
"savings": "Risparmi",
"recentTransactions": "Transazioni recenti",
"budgetOverview": "Panoramica budget"
},
"accounts": {
"title": "Conti",
"add": "Aggiungi conto",
"edit": "Modifica conto",
"delete": "Elimina conto",
"name": "Nome conto",
"type": "Tipo conto",
"balance": "Saldo",
"currency": "Valuta",
"noAccounts": "Nessun conto",
"types": {
"checking": "Conto corrente",
"savings": "Conto risparmio",
"credit": "Carta di credito",
"cash": "Contanti",
"investment": "Investimento"
}
},
"transactions": {
"title": "Transazioni",
"add": "Aggiungi transazione",
"edit": "Modifica transazione",
"delete": "Elimina transazione",
"amount": "Importo",
"date": "Data",
"description": "Descrizione",
"category": "Categoria",
"account": "Conto",
"type": "Tipo",
"noTransactions": "Nessuna transazione",
"types": {
"income": "Entrata",
"expense": "Spesa",
"transfer": "Trasferimento"
}
},
"budgets": {
"title": "Budget",
"add": "Aggiungi budget",
"edit": "Modifica budget",
"delete": "Elimina budget",
"name": "Nome budget",
"amount": "Importo",
"spent": "Speso",
"remaining": "Rimanente",
"period": "Periodo",
"category": "Categoria",
"noBudgets": "Nessun budget",
"periods": {
"weekly": "Settimanale",
"monthly": "Mensile",
"yearly": "Annuale"
}
},
"categories": {
"title": "Categorie",
"add": "Aggiungi categoria",
"edit": "Modifica categoria",
"delete": "Elimina categoria",
"name": "Nome",
"icon": "Icona",
"color": "Colore",
"noCategories": "Nessuna categoria"
},
"reports": {
"title": "Report",
"incomeVsExpenses": "Entrate vs. Spese",
"categoryBreakdown": "Suddivisione per categoria",
"trends": "Tendenze",
"export": "Esporta"
},
"settings": {
"title": "Impostazioni",
"general": "Generale",
"appearance": "Aspetto",
"currency": "Valuta predefinita",
"language": "Lingua",
"theme": "Tema",
"darkMode": "Modalità scura",
"notifications": "Notifiche"
},
"common": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"add": "Aggiungi",
"confirm": "Conferma",
"yes": "Sì",
"no": "No",
"ok": "OK",
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo",
"back": "Indietro",
"search": "Cerca",
"filter": "Filtra",
"sort": "Ordina"
}
}

View file

@ -0,0 +1,113 @@
import { accountsApi } from '$lib/api';
import type { Account, CreateAccountInput, UpdateAccountInput } from '@finance/shared';
let accounts = $state<Account[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const accountsStore = {
get accounts() {
return accounts;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get activeAccounts() {
return accounts.filter((a) => !a.isArchived);
},
get totalByCurrency() {
const totals: Record<string, number> = {};
for (const account of accounts.filter((a) => !a.isArchived && a.includeInTotal)) {
const balance = parseFloat(account.balance);
const adjustedBalance =
account.type === 'credit_card' || account.type === 'loan' ? -Math.abs(balance) : balance;
totals[account.currency] = (totals[account.currency] || 0) + adjustedBalance;
}
return totals;
},
async fetchAccounts() {
isLoading = true;
error = null;
try {
accounts = await accountsApi.getAll();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch accounts';
} finally {
isLoading = false;
}
},
async createAccount(data: CreateAccountInput) {
isLoading = true;
error = null;
try {
const newAccount = await accountsApi.create(data);
accounts = [...accounts, newAccount];
return newAccount;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create account';
throw e;
} finally {
isLoading = false;
}
},
async updateAccount(id: string, data: UpdateAccountInput) {
isLoading = true;
error = null;
try {
const updated = await accountsApi.update(id, data);
accounts = accounts.map((a) => (a.id === id ? updated : a));
return updated;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update account';
throw e;
} finally {
isLoading = false;
}
},
async deleteAccount(id: string) {
isLoading = true;
error = null;
try {
await accountsApi.delete(id);
accounts = accounts.filter((a) => a.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete account';
throw e;
} finally {
isLoading = false;
}
},
async archiveAccount(id: string) {
try {
const updated = await accountsApi.archive(id);
accounts = accounts.map((a) => (a.id === id ? updated : a));
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to archive account';
throw e;
}
},
async unarchiveAccount(id: string) {
try {
const updated = await accountsApi.unarchive(id);
accounts = accounts.map((a) => (a.id === id ? updated : a));
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to unarchive account';
throw e;
}
},
getAccountById(id: string) {
return accounts.find((a) => a.id === id);
},
};

View file

@ -0,0 +1,61 @@
import { apiClient } from '$lib/api';
interface User {
id: string;
email: string;
name?: string;
}
let user = $state<User | null>(null);
let token = $state<string | null>(null);
let isLoading = $state(true);
export const authStore = {
get user() {
return user;
},
get token() {
return token;
},
get isLoading() {
return isLoading;
},
get isAuthenticated() {
return !!user && !!token;
},
setToken(newToken: string | null) {
token = newToken;
apiClient.setToken(newToken);
if (newToken && typeof window !== 'undefined') {
localStorage.setItem('finance_token', newToken);
} else if (typeof window !== 'undefined') {
localStorage.removeItem('finance_token');
}
},
setUser(newUser: User | null) {
user = newUser;
},
async init() {
if (typeof window === 'undefined') {
isLoading = false;
return;
}
const savedToken = localStorage.getItem('finance_token');
if (savedToken) {
this.setToken(savedToken);
// TODO: Validate token with backend
}
isLoading = false;
},
logout() {
this.setToken(null);
this.setUser(null);
},
};

View file

@ -0,0 +1,110 @@
import { budgetsApi, type BudgetWithSpending } from '$lib/api/budgets';
import type { CreateBudgetInput, UpdateBudgetInput } from '@finance/shared';
let budgets = $state<BudgetWithSpending[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
let selectedMonth = $state(new Date().getMonth() + 1);
let selectedYear = $state(new Date().getFullYear());
export const budgetsStore = {
get budgets() {
return budgets;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get selectedMonth() {
return selectedMonth;
},
get selectedYear() {
return selectedYear;
},
get totalBudgeted() {
return budgets.reduce((sum, b) => sum + parseFloat(b.amount), 0);
},
get totalSpent() {
return budgets.reduce((sum, b) => sum + b.spent, 0);
},
get overBudgetCount() {
return budgets.filter((b) => b.percentage >= 1).length;
},
setMonth(month: number, year: number) {
selectedMonth = month;
selectedYear = year;
},
async fetchBudgets() {
isLoading = true;
error = null;
try {
budgets = await budgetsApi.getByMonth(selectedYear, selectedMonth);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch budgets';
} finally {
isLoading = false;
}
},
async createBudget(data: CreateBudgetInput) {
isLoading = true;
error = null;
try {
await budgetsApi.create(data);
await this.fetchBudgets();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create budget';
throw e;
} finally {
isLoading = false;
}
},
async updateBudget(id: string, data: UpdateBudgetInput) {
isLoading = true;
error = null;
try {
await budgetsApi.update(id, data);
await this.fetchBudgets();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update budget';
throw e;
} finally {
isLoading = false;
}
},
async deleteBudget(id: string) {
isLoading = true;
error = null;
try {
await budgetsApi.delete(id);
budgets = budgets.filter((b) => b.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete budget';
throw e;
} finally {
isLoading = false;
}
},
async copyFromPreviousMonth() {
try {
const result = await budgetsApi.copyFromPreviousMonth(selectedYear, selectedMonth);
if (result.copied > 0) {
await this.fetchBudgets();
}
return result;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to copy budgets';
throw e;
}
},
};

View file

@ -0,0 +1,104 @@
import { categoriesApi } from '$lib/api';
import type {
Category,
CreateCategoryInput,
UpdateCategoryInput,
CategoryType,
} from '@finance/shared';
let categories = $state<Category[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const categoriesStore = {
get categories() {
return categories;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get expenseCategories() {
return categories.filter((c) => c.type === 'expense' && !c.isArchived);
},
get incomeCategories() {
return categories.filter((c) => c.type === 'income' && !c.isArchived);
},
async fetchCategories(type?: CategoryType) {
isLoading = true;
error = null;
try {
categories = await categoriesApi.getAll(type);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch categories';
} finally {
isLoading = false;
}
},
async createCategory(data: CreateCategoryInput) {
isLoading = true;
error = null;
try {
const newCategory = await categoriesApi.create(data);
categories = [...categories, newCategory];
return newCategory;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create category';
throw e;
} finally {
isLoading = false;
}
},
async updateCategory(id: string, data: UpdateCategoryInput) {
isLoading = true;
error = null;
try {
const updated = await categoriesApi.update(id, data);
categories = categories.map((c) => (c.id === id ? updated : c));
return updated;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update category';
throw e;
} finally {
isLoading = false;
}
},
async deleteCategory(id: string) {
isLoading = true;
error = null;
try {
await categoriesApi.delete(id);
categories = categories.filter((c) => c.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete category';
throw e;
} finally {
isLoading = false;
}
},
async seedCategories() {
try {
const result = await categoriesApi.seed();
if (result.seeded) {
await this.fetchCategories();
}
return result;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to seed categories';
throw e;
}
},
getCategoryById(id: string) {
return categories.find((c) => c.id === id);
},
};

View file

@ -0,0 +1,67 @@
import { reportsApi } from '$lib/api';
interface DashboardData {
totals: { currency: string; amount: number }[];
currentMonth: {
year: number;
month: number;
income: number;
expense: number;
net: number;
};
budgets: {
id: string;
category: { id: string; name: string; color: string } | null;
amount: number;
spent: number;
percentage: number;
}[];
recentTransactions: unknown[];
}
let data = $state<DashboardData | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const dashboardStore = {
get data() {
return data;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get primaryTotal() {
if (!data?.totals?.length) return 0;
// Return EUR total if available, otherwise first currency
const eurTotal = data.totals.find((t) => t.currency === 'EUR');
return eurTotal?.amount ?? data.totals[0]?.amount ?? 0;
},
get monthlyNet() {
return data?.currentMonth?.net ?? 0;
},
get budgetProgress() {
return data?.budgets ?? [];
},
async fetchDashboard() {
isLoading = true;
error = null;
try {
data = await reportsApi.getDashboard();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch dashboard';
} finally {
isLoading = false;
}
},
async refresh() {
await this.fetchDashboard();
},
};

View file

@ -0,0 +1,10 @@
export { authStore } from './auth.svelte';
export { accountsStore } from './accounts.svelte';
export { categoriesStore } from './categories.svelte';
export { transactionsStore } from './transactions.svelte';
export { budgetsStore } from './budgets.svelte';
export { dashboardStore } from './dashboard.svelte';
export { settingsStore } from './settings.svelte';
export { theme } from './theme';
export { isSidebarMode, isNavCollapsed } from './navigation';
export { userSettings } from './user-settings.svelte';

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,67 @@
import { settingsApi } from '$lib/api';
import type { UserSettings, UpdateUserSettingsInput } from '@finance/shared';
const DEFAULT_SETTINGS: UserSettings = {
id: '',
userId: '',
defaultCurrency: 'EUR',
locale: 'de-DE',
dateFormat: 'dd.MM.yyyy',
weekStartsOn: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
let settings = $state<UserSettings>(DEFAULT_SETTINGS);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const settingsStore = {
get settings() {
return settings;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get currency() {
return settings.defaultCurrency;
},
get locale() {
return settings.locale;
},
get dateFormat() {
return settings.dateFormat;
},
async fetchSettings() {
isLoading = true;
error = null;
try {
settings = await settingsApi.get();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch settings';
} finally {
isLoading = false;
}
},
async updateSettings(data: UpdateUserSettingsInput) {
isLoading = true;
error = null;
try {
settings = await settingsApi.update(data);
return settings;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update settings';
throw e;
} finally {
isLoading = false;
}
},
};

View file

@ -0,0 +1,6 @@
import { createTheme, type ThemeStore } from '@manacore/shared-theme';
export const theme: ThemeStore = createTheme({
storagePrefix: 'finance',
variants: ['default', 'blue', 'green', 'purple', 'orange', 'pink'],
});

View file

@ -0,0 +1,112 @@
import { transactionsApi } from '$lib/api';
import type {
Transaction,
CreateTransactionInput,
UpdateTransactionInput,
TransactionFilters,
} from '@finance/shared';
let transactions = $state<Transaction[]>([]);
let total = $state(0);
let isLoading = $state(false);
let error = $state<string | null>(null);
let filters = $state<TransactionFilters>({});
export const transactionsStore = {
get transactions() {
return transactions;
},
get total() {
return total;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get filters() {
return filters;
},
setFilters(newFilters: TransactionFilters) {
filters = { ...filters, ...newFilters };
},
clearFilters() {
filters = {};
},
async fetchTransactions(customFilters?: TransactionFilters) {
isLoading = true;
error = null;
try {
const result = await transactionsApi.getAll(customFilters ?? filters);
transactions = result.data;
total = result.total;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch transactions';
} finally {
isLoading = false;
}
},
async fetchRecent(limit = 10) {
try {
return await transactionsApi.getRecent(limit);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch recent transactions';
throw e;
}
},
async createTransaction(data: CreateTransactionInput) {
isLoading = true;
error = null;
try {
const newTransaction = await transactionsApi.create(data);
transactions = [newTransaction, ...transactions];
total += 1;
return newTransaction;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create transaction';
throw e;
} finally {
isLoading = false;
}
},
async updateTransaction(id: string, data: UpdateTransactionInput) {
isLoading = true;
error = null;
try {
const updated = await transactionsApi.update(id, data);
transactions = transactions.map((t) => (t.id === id ? updated : t));
return updated;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update transaction';
throw e;
} finally {
isLoading = false;
}
},
async deleteTransaction(id: string) {
isLoading = true;
error = null;
try {
await transactionsApi.delete(id);
transactions = transactions.filter((t) => t.id !== id);
total -= 1;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete transaction';
throw e;
} finally {
isLoading = false;
}
},
getTransactionById(id: string) {
return transactions.find((t) => t.id === id);
},
};

View file

@ -0,0 +1,66 @@
/**
* User Settings Store for Finance
* Manages user preferences and settings
*/
interface UserSettings {
currency: string;
locale: string;
dateFormat: string;
nav: {
desktopPosition: 'left' | 'center' | 'right';
};
}
const defaultSettings: UserSettings = {
currency: 'EUR',
locale: 'de',
dateFormat: 'dd.MM.yyyy',
nav: {
desktopPosition: 'center',
},
};
let settings = $state<UserSettings>({ ...defaultSettings });
let isLoaded = $state(false);
export const userSettings = {
get currency() {
return settings.currency;
},
get locale() {
return settings.locale;
},
get dateFormat() {
return settings.dateFormat;
},
get nav() {
return settings.nav;
},
get isLoaded() {
return isLoaded;
},
async load() {
if (typeof window === 'undefined') return;
// Load from localStorage
const saved = localStorage.getItem('finance-user-settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
settings = { ...defaultSettings, ...parsed };
} catch {
// Ignore parse errors
}
}
isLoaded = true;
},
update(updates: Partial<UserSettings>) {
settings = { ...settings, ...updates };
if (typeof localStorage !== 'undefined') {
localStorage.setItem('finance-user-settings', JSON.stringify(settings));
}
},
};

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import {
authStore,
theme,
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
userSettings,
} from '$lib/stores';
// App switcher items
const appItems = getPillAppItems('finance');
let { children } = $props();
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Finance
const navItems: PillNavItem[] = [
{ href: '/', label: 'Dashboard', icon: 'home' },
{ href: '/transactions', label: 'Transaktionen', icon: 'list' },
{ href: '/accounts', label: 'Konten', icon: 'wallet' },
{ href: '/categories', label: 'Kategorien', icon: 'tag' },
{ href: '/budgets', label: 'Budgets', icon: 'pie-chart' },
{ href: '/reports', label: 'Berichte', icon: 'bar-chart' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-7)
const navRoutes = navItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('finance-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('finance-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
authStore.logout();
goto('/login');
}
onMount(async () => {
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Load user settings
await userSettings.load();
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('finance-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('finance-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Floating/Sidebar Pill Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Finance"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition={userSettings.nav.desktopPosition}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#10b981"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<!-- Main Content with dynamic padding based on nav mode -->
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
transition: all 300ms ease;
}
/* Floating nav mode - add top padding for fixed nav */
.main-content.floating-mode {
padding-top: 100px;
}
/* Sidebar mode - add left padding for sidebar nav */
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding-left: 2rem;
padding-right: 2rem;
}
}
</style>

View file

@ -0,0 +1,204 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dashboardStore, accountsStore, transactionsStore } from '$lib/stores';
import { formatCurrency } from '@finance/shared';
onMount(async () => {
await Promise.all([dashboardStore.fetchDashboard(), accountsStore.fetchAccounts()]);
});
</script>
<svelte:head>
<title>Dashboard | Finance</title>
</svelte:head>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Dashboard</h1>
{#if dashboardStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if dashboardStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{dashboardStore.error}</div>
{:else if dashboardStore.data}
<!-- Summary Cards -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Total Balance -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">Gesamtvermögen</h3>
<p class="mt-2 text-3xl font-bold">{formatCurrency(dashboardStore.primaryTotal)}</p>
{#if dashboardStore.data.totals.length > 1}
<div class="mt-2 text-sm text-muted-foreground">
{#each dashboardStore.data.totals as total}
{#if total.currency !== 'EUR'}
<span class="mr-2">{formatCurrency(total.amount, total.currency)}</span>
{/if}
{/each}
</div>
{/if}
</div>
<!-- Monthly Income -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">
Einnahmen ({dashboardStore.data.currentMonth.month}/{dashboardStore.data.currentMonth
.year})
</h3>
<p class="mt-2 text-3xl font-bold text-green-500">
{formatCurrency(dashboardStore.data.currentMonth.income)}
</p>
</div>
<!-- Monthly Expense -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">
Ausgaben ({dashboardStore.data.currentMonth.month}/{dashboardStore.data.currentMonth
.year})
</h3>
<p class="mt-2 text-3xl font-bold text-red-500">
{formatCurrency(dashboardStore.data.currentMonth.expense)}
</p>
</div>
<!-- Monthly Net -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">
Netto ({dashboardStore.data.currentMonth.month}/{dashboardStore.data.currentMonth.year})
</h3>
<p
class="mt-2 text-3xl font-bold {dashboardStore.monthlyNet >= 0
? 'text-green-500'
: 'text-red-500'}"
>
{formatCurrency(dashboardStore.monthlyNet)}
</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- Accounts -->
<div class="rounded-lg border border-border bg-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Konten</h2>
<a href="/accounts" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
{#if accountsStore.activeAccounts.length === 0}
<p class="text-muted-foreground">Noch keine Konten vorhanden.</p>
<a href="/accounts" class="mt-2 inline-block text-sm text-primary hover:underline"
>Konto erstellen</a
>
{:else}
<div class="space-y-3">
{#each accountsStore.activeAccounts.slice(0, 5) as account}
<div class="flex items-center justify-between rounded-lg bg-accent/50 p-3">
<div class="flex items-center gap-3">
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {account.color || '#6b7280'}"
>
<span class="text-white text-sm font-medium">{account.name.charAt(0)}</span>
</div>
<div>
<p class="font-medium">{account.name}</p>
<p class="text-sm text-muted-foreground">{account.type}</p>
</div>
</div>
<p class="font-semibold {parseFloat(account.balance) >= 0 ? '' : 'text-red-500'}">
{formatCurrency(account.balance, account.currency)}
</p>
</div>
{/each}
</div>
{/if}
</div>
<!-- Budget Progress -->
<div class="rounded-lg border border-border bg-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Budget-Fortschritt</h2>
<a href="/budgets" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
{#if dashboardStore.budgetProgress.length === 0}
<p class="text-muted-foreground">Noch keine Budgets definiert.</p>
<a href="/budgets" class="mt-2 inline-block text-sm text-primary hover:underline"
>Budget erstellen</a
>
{:else}
<div class="space-y-4">
{#each dashboardStore.budgetProgress.slice(0, 4) as budget}
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span>{budget.category?.name ?? 'Gesamt'}</span>
<span class="text-muted-foreground"
>{formatCurrency(budget.spent)} / {formatCurrency(budget.amount)}</span
>
</div>
<div class="h-2 overflow-hidden rounded-full bg-accent">
<div
class="h-full transition-all {budget.percentage >= 1
? 'bg-red-500'
: budget.percentage >= 0.8
? 'bg-yellow-500'
: 'bg-green-500'}"
style="width: {Math.min(budget.percentage * 100, 100)}%"
></div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Recent Transactions -->
<div class="rounded-lg border border-border bg-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Letzte Transaktionen</h2>
<a href="/transactions" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
{#if dashboardStore.data.recentTransactions.length === 0}
<p class="text-muted-foreground">Noch keine Transaktionen vorhanden.</p>
<a href="/transactions" class="mt-2 inline-block text-sm text-primary hover:underline"
>Transaktion erstellen</a
>
{:else}
<div class="space-y-2">
{#each dashboardStore.data.recentTransactions as transaction}
<div class="flex items-center justify-between rounded-lg bg-accent/50 p-3">
<div class="flex items-center gap-3">
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {(transaction as any).category?.color || '#6b7280'}"
>
<span class="text-white text-sm"
>{(transaction as any).category?.name?.charAt(0) ?? '?'}</span
>
</div>
<div>
<p class="font-medium">
{(transaction as any).description ||
(transaction as any).payee ||
'Keine Beschreibung'}
</p>
<p class="text-sm text-muted-foreground">{(transaction as any).date}</p>
</div>
</div>
<p
class="font-semibold {(transaction as any).type === 'income'
? 'text-green-500'
: 'text-red-500'}"
>
{(transaction as any).type === 'income' ? '+' : '-'}{formatCurrency(
(transaction as any).amount
)}
</p>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte';
import { accountsStore } from '$lib/stores';
import { formatCurrency, ACCOUNT_TYPE_LABELS } from '@finance/shared';
let showArchived = $state(false);
onMount(async () => {
await accountsStore.fetchAccounts();
});
const displayedAccounts = $derived(
showArchived ? accountsStore.accounts : accountsStore.activeAccounts
);
const accountsByType = $derived(() => {
const grouped: Record<string, typeof accountsStore.accounts> = {};
for (const account of displayedAccounts) {
if (!grouped[account.type]) {
grouped[account.type] = [];
}
grouped[account.type].push(account);
}
return grouped;
});
</script>
<svelte:head>
<title>Konten | Finance</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Konten</h1>
<div class="flex gap-2">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={showArchived} class="rounded" />
<span class="text-sm">Archivierte anzeigen</span>
</label>
<a
href="/accounts/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
+ Neues Konto
</a>
</div>
</div>
<!-- Totals by Currency -->
<div class="grid gap-4 md:grid-cols-3">
{#each Object.entries(accountsStore.totalByCurrency) as [currency, total]}
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="text-sm font-medium text-muted-foreground">Gesamtsaldo ({currency})</h3>
<p class="mt-1 text-2xl font-bold {total >= 0 ? 'text-green-500' : 'text-red-500'}">
{formatCurrency(total, currency)}
</p>
</div>
{/each}
</div>
<!-- Accounts List -->
{#if accountsStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if accountsStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{accountsStore.error}</div>
{:else if displayedAccounts.length === 0}
<div class="rounded-lg border border-border bg-card p-12 text-center">
<p class="text-muted-foreground">Noch keine Konten vorhanden.</p>
<a
href="/accounts/new"
class="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Erstes Konto erstellen
</a>
</div>
{:else}
{#each Object.entries(accountsByType()) as [type, accounts]}
<div class="space-y-2">
<h2 class="text-lg font-semibold">
{ACCOUNT_TYPE_LABELS[type as keyof typeof ACCOUNT_TYPE_LABELS]?.de ?? type}
</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each accounts as account}
<a
href="/accounts/{account.id}"
class="rounded-lg border border-border bg-card p-4 hover:bg-accent/50 {account.isArchived
? 'opacity-60'
: ''}"
>
<div class="flex items-center gap-3">
<div
class="h-12 w-12 rounded-full flex items-center justify-center"
style="background-color: {account.color || '#6b7280'}"
>
<span class="text-white font-semibold">{account.name.charAt(0)}</span>
</div>
<div class="flex-1">
<p class="font-medium">{account.name}</p>
<p class="text-sm text-muted-foreground">{account.currency}</p>
</div>
</div>
<div class="mt-4">
<p
class="text-2xl font-bold {parseFloat(account.balance) >= 0
? ''
: 'text-red-500'}"
>
{formatCurrency(account.balance, account.currency)}
</p>
{#if account.isArchived}
<span class="text-xs text-muted-foreground">Archiviert</span>
{/if}
</div>
</a>
{/each}
</div>
</div>
{/each}
{/if}
</div>

View file

@ -0,0 +1,184 @@
<script lang="ts">
import { onMount } from 'svelte';
import { budgetsStore, categoriesStore } from '$lib/stores';
import { formatCurrency, calculateBudgetPercentage, getBudgetStatus } from '@finance/shared';
const months = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
];
onMount(async () => {
await Promise.all([budgetsStore.fetchBudgets(), categoriesStore.fetchCategories()]);
});
function previousMonth() {
if (budgetsStore.selectedMonth === 1) {
budgetsStore.setMonth(12, budgetsStore.selectedYear - 1);
} else {
budgetsStore.setMonth(budgetsStore.selectedMonth - 1, budgetsStore.selectedYear);
}
budgetsStore.fetchBudgets();
}
function nextMonth() {
if (budgetsStore.selectedMonth === 12) {
budgetsStore.setMonth(1, budgetsStore.selectedYear + 1);
} else {
budgetsStore.setMonth(budgetsStore.selectedMonth + 1, budgetsStore.selectedYear);
}
budgetsStore.fetchBudgets();
}
async function copyFromPrevious() {
await budgetsStore.copyFromPreviousMonth();
}
function getStatusColor(percentage: number) {
const status = getBudgetStatus(percentage * 100);
switch (status) {
case 'over':
return 'bg-red-500';
case 'danger':
return 'bg-red-400';
case 'warning':
return 'bg-yellow-500';
default:
return 'bg-green-500';
}
}
</script>
<svelte:head>
<title>Budgets | Finance</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Budgets</h1>
<a
href="/budgets/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
+ Neues Budget
</a>
</div>
<!-- Month Selector -->
<div class="flex items-center justify-center gap-4">
<button onclick={previousMonth} class="rounded-lg border border-border p-2 hover:bg-accent"
>&larr;</button
>
<span class="text-lg font-semibold">
{months[budgetsStore.selectedMonth - 1]}
{budgetsStore.selectedYear}
</span>
<button onclick={nextMonth} class="rounded-lg border border-border p-2 hover:bg-accent"
>&rarr;</button
>
</div>
<!-- Summary -->
<div class="grid gap-4 md:grid-cols-3">
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="text-sm font-medium text-muted-foreground">Budgetiert</h3>
<p class="mt-1 text-2xl font-bold">{formatCurrency(budgetsStore.totalBudgeted)}</p>
</div>
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="text-sm font-medium text-muted-foreground">Ausgegeben</h3>
<p class="mt-1 text-2xl font-bold text-red-500">{formatCurrency(budgetsStore.totalSpent)}</p>
</div>
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="text-sm font-medium text-muted-foreground">Verbleibend</h3>
<p
class="mt-1 text-2xl font-bold {budgetsStore.totalBudgeted - budgetsStore.totalSpent >= 0
? 'text-green-500'
: 'text-red-500'}"
>
{formatCurrency(budgetsStore.totalBudgeted - budgetsStore.totalSpent)}
</p>
</div>
</div>
{#if budgetsStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if budgetsStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{budgetsStore.error}</div>
{:else if budgetsStore.budgets.length === 0}
<div class="rounded-lg border border-border bg-card p-12 text-center">
<p class="text-muted-foreground">Keine Budgets für diesen Monat definiert.</p>
<div class="mt-4 flex justify-center gap-4">
<button
onclick={copyFromPrevious}
class="rounded-lg border border-border px-4 py-2 hover:bg-accent"
>
Vom Vormonat kopieren
</button>
<a
href="/budgets/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Budget erstellen
</a>
</div>
</div>
{:else}
<div class="space-y-4">
{#each budgetsStore.budgets as budget}
<div class="rounded-lg border border-border bg-card p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{#if budget.category}
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {budget.category.color || '#6b7280'}"
>
<span class="text-white font-medium">{budget.category.name.charAt(0)}</span>
</div>
<span class="font-medium">{budget.category.name}</span>
{:else}
<div class="h-10 w-10 rounded-full bg-gray-500 flex items-center justify-center">
<span class="text-white font-medium">G</span>
</div>
<span class="font-medium">Gesamtbudget</span>
{/if}
</div>
<div class="text-right">
<p class="font-semibold">
{formatCurrency(budget.spent)} / {formatCurrency(budget.amount)}
</p>
<p class="text-sm {budget.remaining >= 0 ? 'text-green-500' : 'text-red-500'}">
{budget.remaining >= 0
? `${formatCurrency(budget.remaining)} übrig`
: `${formatCurrency(Math.abs(budget.remaining))} über Budget`}
</p>
</div>
</div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-accent">
<div
class="h-full transition-all {getStatusColor(budget.percentage)}"
style="width: {Math.min(budget.percentage * 100, 100)}%"
></div>
</div>
<p class="mt-1 text-right text-sm text-muted-foreground">
{Math.round(budget.percentage * 100)}%
</p>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,110 @@
<script lang="ts">
import { onMount } from 'svelte';
import { categoriesStore } from '$lib/stores';
onMount(async () => {
await categoriesStore.fetchCategories();
});
async function seedCategories() {
await categoriesStore.seedCategories();
}
</script>
<svelte:head>
<title>Kategorien | Finance</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Kategorien</h1>
<div class="flex gap-2">
{#if categoriesStore.categories.length === 0}
<button
onclick={seedCategories}
class="rounded-lg border border-border px-4 py-2 hover:bg-accent"
>
Standard-Kategorien laden
</button>
{/if}
<a
href="/categories/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
+ Neue Kategorie
</a>
</div>
</div>
{#if categoriesStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if categoriesStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{categoriesStore.error}</div>
{:else}
<div class="grid gap-6 lg:grid-cols-2">
<!-- Expense Categories -->
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-red-500">Ausgaben</h2>
{#if categoriesStore.expenseCategories.length === 0}
<p class="text-muted-foreground">Keine Ausgaben-Kategorien vorhanden.</p>
{:else}
<div class="space-y-2">
{#each categoriesStore.expenseCategories as category}
<a
href="/categories/{category.id}"
class="flex items-center gap-3 rounded-lg p-3 hover:bg-accent/50"
>
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {category.color || '#ef4444'}"
>
<span class="text-white font-medium">{category.name.charAt(0)}</span>
</div>
<div class="flex-1">
<p class="font-medium">{category.name}</p>
{#if category.isSystem}
<span class="text-xs text-muted-foreground">Standard</span>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</div>
<!-- Income Categories -->
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-green-500">Einnahmen</h2>
{#if categoriesStore.incomeCategories.length === 0}
<p class="text-muted-foreground">Keine Einnahmen-Kategorien vorhanden.</p>
{:else}
<div class="space-y-2">
{#each categoriesStore.incomeCategories as category}
<a
href="/categories/{category.id}"
class="flex items-center gap-3 rounded-lg p-3 hover:bg-accent/50"
>
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {category.color || '#22c55e'}"
>
<span class="text-white font-medium">{category.name.charAt(0)}</span>
</div>
<div class="flex-1">
<p class="font-medium">{category.name}</p>
{#if category.isSystem}
<span class="text-xs text-muted-foreground">Standard</span>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
const feedbackService = createFeedbackService({
appName: 'finance',
apiUrl: 'http://localhost:3001', // Mana Core API
});
async function handleSubmit(data: { type: string; message: string; email?: string }) {
const token = await authStore.getAccessToken();
return feedbackService.submit({
...data,
token: token || undefined,
});
}
</script>
<FeedbackPage appName="Finance" onSubmit={handleSubmit} userEmail={authStore.user?.email} />

View file

@ -0,0 +1,129 @@
<script lang="ts">
import { onMount } from 'svelte';
import { reportsApi } from '$lib/api';
import { formatCurrency, getMonthDateRange, getCurrentMonthYear } from '@finance/shared';
let trends = $state<
{ year: number; month: number; income: number; expense: number; net: number }[]
>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
const months = [
'Jan',
'Feb',
'Mär',
'Apr',
'Mai',
'Jun',
'Jul',
'Aug',
'Sep',
'Okt',
'Nov',
'Dez',
];
onMount(async () => {
try {
const result = await reportsApi.getTrends(6);
trends = result.data;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load reports';
} finally {
isLoading = false;
}
});
const maxAmount = $derived(Math.max(...trends.flatMap((t) => [t.income, t.expense]), 1));
</script>
<svelte:head>
<title>Berichte | Finance</title>
</svelte:head>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Berichte</h1>
<!-- Quick Links -->
<div class="grid gap-4 md:grid-cols-3">
<a
href="/reports/monthly"
class="rounded-lg border border-border bg-card p-6 hover:bg-accent/50"
>
<h3 class="font-semibold">Monatsübersicht</h3>
<p class="mt-1 text-sm text-muted-foreground">Detaillierte Aufschlüsselung nach Kategorie</p>
</a>
<a
href="/reports/trends"
class="rounded-lg border border-border bg-card p-6 hover:bg-accent/50"
>
<h3 class="font-semibold">Trends</h3>
<p class="mt-1 text-sm text-muted-foreground">Ausgaben und Einnahmen über Zeit</p>
</a>
<a href="/settings" class="rounded-lg border border-border bg-card p-6 hover:bg-accent/50">
<h3 class="font-semibold">Export</h3>
<p class="mt-1 text-sm text-muted-foreground">Daten als CSV exportieren</p>
</a>
</div>
<!-- Trends Preview -->
{#if isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{error}</div>
{:else}
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-6 text-lg font-semibold">Letzte 6 Monate</h2>
<!-- Simple Bar Chart -->
<div class="space-y-4">
{#each trends as month}
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">{months[month.month - 1]} {month.year}</span>
<span class="text-muted-foreground"
>Netto: <span class={month.net >= 0 ? 'text-green-500' : 'text-red-500'}
>{formatCurrency(month.net)}</span
></span
>
</div>
<div class="flex gap-2">
<div class="flex-1">
<div class="h-4 overflow-hidden rounded bg-green-100 dark:bg-green-900/30">
<div
class="h-full bg-green-500"
style="width: {(month.income / maxAmount) * 100}%"
></div>
</div>
<span class="text-xs text-green-600">{formatCurrency(month.income)}</span>
</div>
<div class="flex-1">
<div class="h-4 overflow-hidden rounded bg-red-100 dark:bg-red-900/30">
<div
class="h-full bg-red-500"
style="width: {(month.expense / maxAmount) * 100}%"
></div>
</div>
<span class="text-xs text-red-600">{formatCurrency(month.expense)}</span>
</div>
</div>
</div>
{/each}
</div>
<div class="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
<span class="flex items-center gap-1"
><span class="h-3 w-3 rounded bg-green-500"></span> Einnahmen</span
>
<span class="flex items-center gap-1"
><span class="h-3 w-3 rounded bg-red-500"></span> Ausgaben</span
>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from 'svelte';
import { settingsStore } from '$lib/stores';
import { CURRENCIES, DATE_FORMATS, WEEK_START_OPTIONS } from '@finance/shared';
import type { UpdateUserSettingsInput } from '@finance/shared';
let isSaving = $state(false);
let successMessage = $state<string | null>(null);
onMount(async () => {
await settingsStore.fetchSettings();
});
async function saveSettings(e: Event) {
e.preventDefault();
isSaving = true;
successMessage = null;
try {
const data: UpdateUserSettingsInput = {
defaultCurrency: settingsStore.settings.defaultCurrency,
locale: settingsStore.settings.locale,
dateFormat: settingsStore.settings.dateFormat,
weekStartsOn: settingsStore.settings.weekStartsOn,
};
await settingsStore.updateSettings(data);
successMessage = 'Einstellungen gespeichert!';
setTimeout(() => (successMessage = null), 3000);
} catch (e) {
// Error handled by store
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Einstellungen | Finance</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold">Einstellungen</h1>
{#if settingsStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else}
<form onsubmit={saveSettings} class="space-y-6">
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold">Regionale Einstellungen</h2>
<div class="space-y-4">
<div>
<label for="currency" class="mb-1 block text-sm font-medium">Standard-Währung</label>
<select
id="currency"
bind:value={settingsStore.settings.defaultCurrency}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
{#each CURRENCIES as currency}
<option value={currency.code}
>{currency.code} - {currency.name.de} ({currency.symbol})</option
>
{/each}
</select>
</div>
<div>
<label for="locale" class="mb-1 block text-sm font-medium">Sprache / Region</label>
<select
id="locale"
bind:value={settingsStore.settings.locale}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
<option value="de-DE">Deutsch (Deutschland)</option>
<option value="de-AT">Deutsch (Österreich)</option>
<option value="de-CH">Deutsch (Schweiz)</option>
<option value="en-US">English (US)</option>
<option value="en-GB">English (UK)</option>
</select>
</div>
<div>
<label for="dateFormat" class="mb-1 block text-sm font-medium">Datumsformat</label>
<select
id="dateFormat"
bind:value={settingsStore.settings.dateFormat}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
{#each DATE_FORMATS as format}
<option value={format.value}>{format.label}</option>
{/each}
</select>
</div>
<div>
<label for="weekStart" class="mb-1 block text-sm font-medium">Woche beginnt am</label>
<select
id="weekStart"
bind:value={settingsStore.settings.weekStartsOn}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
{#each WEEK_START_OPTIONS as option}
<option value={option.value}>{option.label.de}</option>
{/each}
</select>
</div>
</div>
</div>
{#if settingsStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{settingsStore.error}</div>
{/if}
{#if successMessage}
<div class="rounded-lg bg-green-500/10 p-4 text-green-600">{successMessage}</div>
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={isSaving}
class="rounded-lg bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</form>
<!-- Danger Zone -->
<div class="rounded-lg border border-destructive bg-destructive/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-destructive">Gefahrenzone</h2>
<p class="mb-4 text-sm text-muted-foreground">
Diese Aktionen können nicht rückgängig gemacht werden.
</p>
<button
class="rounded-lg border border-destructive px-4 py-2 text-destructive hover:bg-destructive/10"
>
Alle Daten löschen
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { transactionsStore, categoriesStore, accountsStore } from '$lib/stores';
import { formatCurrency, formatDate } from '@finance/shared';
import type { TransactionFilters } from '@finance/shared';
let showFilters = $state(false);
let filters = $state<TransactionFilters>({});
onMount(async () => {
await Promise.all([
transactionsStore.fetchTransactions(),
categoriesStore.fetchCategories(),
accountsStore.fetchAccounts(),
]);
});
async function applyFilters() {
transactionsStore.setFilters(filters);
await transactionsStore.fetchTransactions();
}
async function clearFilters() {
filters = {};
transactionsStore.clearFilters();
await transactionsStore.fetchTransactions();
}
</script>
<svelte:head>
<title>Transaktionen | Finance</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Transaktionen</h1>
<div class="flex gap-2">
<button
onclick={() => (showFilters = !showFilters)}
class="rounded-lg border border-border px-4 py-2 hover:bg-accent"
>
{showFilters ? 'Filter ausblenden' : 'Filter anzeigen'}
</button>
<a
href="/transactions/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
+ Neu
</a>
</div>
</div>
<!-- Filters -->
{#if showFilters}
<div class="rounded-lg border border-border bg-card p-4">
<div class="grid gap-4 md:grid-cols-4">
<div>
<label class="mb-1 block text-sm font-medium">Konto</label>
<select
bind:value={filters.accountId}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
<option value="">Alle Konten</option>
{#each accountsStore.activeAccounts as account}
<option value={account.id}>{account.name}</option>
{/each}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Kategorie</label>
<select
bind:value={filters.categoryId}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
<option value="">Alle Kategorien</option>
{#each categoriesStore.categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Typ</label>
<select
bind:value={filters.type}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
<option value="">Alle</option>
<option value="income">Einnahme</option>
<option value="expense">Ausgabe</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Suche</label>
<input
type="text"
bind:value={filters.search}
placeholder="Beschreibung, Empfänger..."
class="w-full rounded-lg border border-border bg-background px-3 py-2"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Von</label>
<input
type="date"
bind:value={filters.startDate}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Bis</label>
<input
type="date"
bind:value={filters.endDate}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
/>
</div>
</div>
<div class="mt-4 flex gap-2">
<button
onclick={applyFilters}
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Filter anwenden
</button>
<button
onclick={clearFilters}
class="rounded-lg border border-border px-4 py-2 hover:bg-accent"
>
Zurücksetzen
</button>
</div>
</div>
{/if}
<!-- Transaction List -->
{#if transactionsStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if transactionsStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{transactionsStore.error}</div>
{:else if transactionsStore.transactions.length === 0}
<div class="rounded-lg border border-border bg-card p-12 text-center">
<p class="text-muted-foreground">Keine Transaktionen gefunden.</p>
<a
href="/transactions/new"
class="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Erste Transaktion erstellen
</a>
</div>
{:else}
<div class="rounded-lg border border-border bg-card">
<div class="divide-y divide-border">
{#each transactionsStore.transactions as transaction}
<a
href="/transactions/{transaction.id}"
class="flex items-center justify-between p-4 hover:bg-accent/50"
>
<div class="flex items-center gap-4">
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {transaction.category?.color || '#6b7280'}"
>
<span class="text-white text-sm font-medium"
>{transaction.category?.name?.charAt(0) ?? '?'}</span
>
</div>
<div>
<p class="font-medium">
{transaction.description || transaction.payee || 'Keine Beschreibung'}
</p>
<p class="text-sm text-muted-foreground">
{transaction.category?.name ?? 'Keine Kategorie'}{formatDate(transaction.date)}
{transaction.account?.name}
</p>
</div>
</div>
<div class="text-right">
<p
class="font-semibold {transaction.type === 'income'
? 'text-green-500'
: 'text-red-500'}"
>
{transaction.type === 'income' ? '+' : '-'}{formatCurrency(
transaction.amount,
transaction.currency
)}
</p>
{#if transaction.isPending}
<span class="text-xs text-yellow-500">Ausstehend</span>
{/if}
</div>
</a>
{/each}
</div>
</div>
<!-- Pagination info -->
<div class="text-center text-sm text-muted-foreground">
{transactionsStore.transactions.length} von {transactionsStore.total} Transaktionen
</div>
{/if}
</div>

View file

@ -0,0 +1,75 @@
<script lang="ts">
let email = $state('');
let error = $state<string | null>(null);
let success = $state(false);
let isLoading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
isLoading = true;
error = null;
try {
// TODO: Implement password reset via mana-core-auth
console.log('Reset password for:', email);
error =
'Passwort-Zurücksetzen noch nicht implementiert. Bitte verwenden Sie das ManaCore Auth System.';
} catch (e) {
error = e instanceof Error ? e.message : 'Anfrage fehlgeschlagen';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Passwort vergessen | Finance</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center">
<div class="w-full max-w-md space-y-6 rounded-lg border border-border bg-card p-8">
<div class="text-center">
<h1 class="text-2xl font-bold">Passwort vergessen</h1>
<p class="mt-2 text-muted-foreground">
Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen
</p>
</div>
{#if success}
<div class="rounded-lg bg-green-500/10 p-4 text-center text-green-600">
<p class="font-medium">E-Mail gesendet!</p>
<p class="mt-1 text-sm">Überprüfen Sie Ihren Posteingang für weitere Anweisungen.</p>
</div>
{:else}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="ihre@email.de"
/>
</div>
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="w-full rounded-lg bg-primary py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Senden...' : 'Link senden'}
</button>
</form>
{/if}
<div class="text-center text-sm text-muted-foreground">
<a href="/login" class="text-primary hover:underline">Zurück zur Anmeldung</a>
</div>
</div>
</div>

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores';
let email = $state('');
let password = $state('');
let error = $state<string | null>(null);
let isLoading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
isLoading = true;
error = null;
try {
// TODO: Implement login via mana-core-auth
// For now, just a placeholder
console.log('Login:', email, password);
error = 'Login noch nicht implementiert. Bitte verwenden Sie das ManaCore Auth System.';
} catch (e) {
error = e instanceof Error ? e.message : 'Login fehlgeschlagen';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Login | Finance</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center">
<div class="w-full max-w-md space-y-6 rounded-lg border border-border bg-card p-8">
<div class="text-center">
<h1 class="text-2xl font-bold">Anmelden</h1>
<p class="mt-2 text-muted-foreground">Melden Sie sich bei Finance an</p>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="ihre@email.de"
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="••••••••"
/>
</div>
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="w-full rounded-lg bg-primary py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
<div class="text-center text-sm">
<a href="/forgot-password" class="text-primary hover:underline">Passwort vergessen?</a>
</div>
<div class="text-center text-sm text-muted-foreground">
Noch kein Konto? <a href="/register" class="text-primary hover:underline">Registrieren</a>
</div>
</div>
</div>

View file

@ -0,0 +1,113 @@
<script lang="ts">
let name = $state('');
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state<string | null>(null);
let isLoading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (password !== confirmPassword) {
error = 'Passwörter stimmen nicht überein';
return;
}
isLoading = true;
error = null;
try {
// TODO: Implement registration via mana-core-auth
console.log('Register:', name, email, password);
error =
'Registrierung noch nicht implementiert. Bitte verwenden Sie das ManaCore Auth System.';
} catch (e) {
error = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Registrieren | Finance</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center">
<div class="w-full max-w-md space-y-6 rounded-lg border border-border bg-card p-8">
<div class="text-center">
<h1 class="text-2xl font-bold">Registrieren</h1>
<p class="mt-2 text-muted-foreground">Erstellen Sie ein neues Konto</p>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="name" class="mb-1 block text-sm font-medium">Name</label>
<input
id="name"
type="text"
bind:value={name}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="Max Mustermann"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="ihre@email.de"
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
required
minlength="8"
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="••••••••"
/>
</div>
<div>
<label for="confirmPassword" class="mb-1 block text-sm font-medium"
>Passwort bestätigen</label
>
<input
id="confirmPassword"
type="password"
bind:value={confirmPassword}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="••••••••"
/>
</div>
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="w-full rounded-lg bg-primary py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Registrieren...' : 'Registrieren'}
</button>
</form>
<div class="text-center text-sm text-muted-foreground">
Bereits ein Konto? <a href="/login" class="text-primary hover:underline">Anmelden</a>
</div>
</div>
</div>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import '../app.css';
import '$lib/i18n';
import { onMount } from 'svelte';
import { authStore, theme } from '$lib/stores';
let { children } = $props();
let loading = $state(true);
onMount(async () => {
// Initialize theme
theme.initialize();
// Initialize auth
await authStore.init();
loading = false;
});
</script>
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}