Production deployment setup + feature complete
- Dockerfile + deploy.sh for Hetzner server - Email verification via Better Auth + Resend - Invite code flow (6-digit OTP, generate/join) - Settlement share percent fix (payer vs debtor) - OCR scanner fixes (date display, retry, viewfinder) - app.json icon/splash/adaptive-icon configured - iOS deployment target 15.5 (ML Kit requirement) - DB migration 0014: household_invitations table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -49,3 +49,10 @@ coverage
|
|||||||
.cache
|
.cache
|
||||||
tmp
|
tmp
|
||||||
temp
|
temp
|
||||||
|
|
||||||
|
# Expo prebuild (generated native code)
|
||||||
|
apps/native/ios/
|
||||||
|
apps/native/android/
|
||||||
|
|
||||||
|
# Production env
|
||||||
|
apps/native/.env.production
|
||||||
|
|||||||
181
CLAUDE.md
Normal file
181
CLAUDE.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# HaushaltsApp — Claude Instructions
|
||||||
|
|
||||||
|
## Projektübersicht
|
||||||
|
|
||||||
|
Mobile-SaaS App für Haushaltsverwaltung: Haushaltsbuch, Urlaubsbudget, Echtzeit-Einkaufsliste.
|
||||||
|
|
||||||
|
**Stack:**
|
||||||
|
- **Monorepo:** Bun Workspaces + Turborepo
|
||||||
|
- **Backend:** Hono (Bun) — `apps/server/`
|
||||||
|
- **Mobile:** React Native + Expo + expo-router — `apps/native/`
|
||||||
|
- **Web (Dashboard):** React + TanStack Router + Vite — `apps/web/`
|
||||||
|
- **Database:** PostgreSQL + Drizzle ORM — `packages/db/`
|
||||||
|
- **Auth:** Better Auth — `packages/auth/`
|
||||||
|
- **Shared Types/Schemas:** Zod — `packages/shared/`
|
||||||
|
- **UI (Web):** shadcn/ui (base-lyra) — `packages/ui/`
|
||||||
|
- **UI (Native):** HeroUI Native + Uniwind
|
||||||
|
|
||||||
|
## Architektur-Entscheidungen
|
||||||
|
|
||||||
|
- **Multi-Tenant via `householdId`:** Jeder Haushalt ist ein Tenant. Alle Daten (Transaktionen, Einkaufslisten etc.) sind an eine `householdId` gebunden. `householdId` wird via `x-household-id` HTTP-Header übergeben.
|
||||||
|
- **Subscription Plans:** `free`, `pro`, `family` — Feature-Gates in `plan.middleware.ts`, Definitionen in `packages/shared/src/constants/plans.ts`.
|
||||||
|
- **Better Auth** übernimmt User/Session-Management. Die `packages/auth/` enthält die Server-seitige Konfiguration.
|
||||||
|
- **Shared Package (`@haushaltsApp/shared`):** Zod-Schemas und TypeScript-Types werden im shared package definiert und sowohl vom Backend als auch vom Frontend verwendet.
|
||||||
|
- **Drizzle Schema:** Alle App-Tabellen in `packages/db/src/schema/app.ts`, Auth-Tabellen in `packages/db/src/schema/auth.ts`.
|
||||||
|
|
||||||
|
## Development starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Services starten
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Einzelne Services
|
||||||
|
bun run dev:server # API auf http://localhost:3000
|
||||||
|
bun run dev:web # Web auf http://localhost:3001
|
||||||
|
bun run dev:native # Expo (Metro Bundler)
|
||||||
|
|
||||||
|
# Datenbank
|
||||||
|
bun run db:start # PostgreSQL via Docker starten
|
||||||
|
bun run db:generate # Drizzle Migrationen generieren
|
||||||
|
bun run db:migrate # Migrationen anwenden
|
||||||
|
bun run db:studio # Drizzle Studio öffnen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing-Konventionen
|
||||||
|
|
||||||
|
### Backend (`apps/server/`)
|
||||||
|
- **Framework:** `bun:test` (built-in, kein extra Package)
|
||||||
|
- **Ort:** `apps/server/src/__tests__/routes/` und `apps/server/src/__tests__/services/`
|
||||||
|
- **Konvention:** Route-Tests testen den HTTP-Layer direkt via `app.request()`. Services werden unit-getestet.
|
||||||
|
- **Ausführen:** `bun run test:api` oder `bun test apps/server/src/__tests__`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Beispiel Route-Test
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import app from "../../index";
|
||||||
|
|
||||||
|
describe("GET /health", () => {
|
||||||
|
it("returns 200 with status ok", async () => {
|
||||||
|
const res = await app.request("/health");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile (`apps/native/`)
|
||||||
|
- **Framework:** `bun:test` — kein Jest/Babel/jest-expo (Jest + Bun's .bun/ Store sind inkompatibel mit RN's ESM setup files)
|
||||||
|
- **Was wird getestet:** Stores, Hooks, Utils, API-Client — reine Business Logic, kein React Native Rendering
|
||||||
|
- **Was NICHT getestet wird:** UI-Komponenten, Screens — Rendering wird manuell via Expo Go verifiziert
|
||||||
|
- **Ort:** `apps/native/src/__tests__/` spiegelt `apps/native/src/` Struktur
|
||||||
|
- **Ausführen:** `bun run test:mobile` oder `bun test apps/native/src/__tests__`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Beispiel Store-Test
|
||||||
|
import { describe, expect, it, beforeEach } from "bun:test";
|
||||||
|
import { useAuthStore } from "../../stores/auth.store";
|
||||||
|
|
||||||
|
describe("authStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({ user: null, token: null, isAuthenticated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setUser authenticates the user", () => {
|
||||||
|
useAuthStore.getState().setUser({ id: "1", name: "Test", email: "t@t.com" });
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding-Konventionen
|
||||||
|
|
||||||
|
### Allgemein
|
||||||
|
- **TypeScript strict mode** überall — kein `any`, kein type-casting ohne Kommentar
|
||||||
|
- **Zod-first:** Alle API-Inputs werden mit Zod-Schemas aus `@haushaltsApp/shared` validiert
|
||||||
|
- **Named exports** bevorzugen (default exports nur bei Expo/React Router Screens)
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
- **Files:** `kebab-case.ts` / `PascalCase.tsx` für React-Komponenten
|
||||||
|
- **Variables/Functions:** `camelCase`
|
||||||
|
- **Types/Interfaces:** `PascalCase`
|
||||||
|
- **Database tables:** `snake_case` (Drizzle convention)
|
||||||
|
- **Zod schemas:** `camelCaseSchema` (z.B. `createTransactionSchema`)
|
||||||
|
- **Route files:** `feature.routes.ts`
|
||||||
|
- **Middleware files:** `feature.middleware.ts`
|
||||||
|
- **Service files:** `feature.service.ts`
|
||||||
|
|
||||||
|
### API-Design
|
||||||
|
- Alle Endpoints unter `/api/` prefix
|
||||||
|
- Auth-Check via `authMiddleware` + `requireAuth`
|
||||||
|
- Tenant-Check via `tenantMiddleware` + `requireHousehold`
|
||||||
|
- Plan-Gates via `requireFeature('featureName')`
|
||||||
|
- HTTP-Status-Codes: 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found
|
||||||
|
|
||||||
|
### Ordnerstruktur (Backend)
|
||||||
|
```
|
||||||
|
apps/server/src/
|
||||||
|
├── routes/ # HTTP-Layer, minimale Logik
|
||||||
|
├── services/ # Business Logic, kein direkter DB-Code in routes
|
||||||
|
├── middleware/ # Auth, Tenant, Plan Feature Gates
|
||||||
|
├── websocket/ # WebSocket Handler
|
||||||
|
├── lib/ # DB-Instance, Utilities
|
||||||
|
└── __tests__/ # Tests spiegeln src/-Struktur
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management (Mobile)
|
||||||
|
- **Zustand** für globalen Client-State (User, aktueller Haushalt)
|
||||||
|
- **TanStack Query** für Server-State (Daten aus der API)
|
||||||
|
- Kein direktes Fetch in Komponenten — immer Custom Hooks oder TanStack Query
|
||||||
|
|
||||||
|
## Known Constraints
|
||||||
|
|
||||||
|
### React Native Testing
|
||||||
|
Jest + Bun's Content-Addressable Store (`.bun/`) sind strukturell inkompatibel für RN Rendering-Tests. `jest-expo` setzt `setupFiles` mit absoluten `.bun/` Pfaden — kein Resolver oder `transformIgnorePatterns` kann das abfangen.
|
||||||
|
|
||||||
|
**Entscheidung:** `bun:test` für Business Logic (Stores, Hooks, Utils, Services). Rendering wird via Expo Go verifiziert. Rendering-Tests werden nachgereicht wenn Bun/jest-expo das nativ lösen.
|
||||||
|
|
||||||
|
### Web App (`apps/web`)
|
||||||
|
TanStack Router generiert `routeTree.gen.ts` erst bei `vite dev`. `check-types` für web daher nur nach einmaligem dev-Run aussagekräftig.
|
||||||
|
|
||||||
|
### Better Auth Mobile Setup
|
||||||
|
- `bearer` Plugin ist Pflicht in `packages/auth/src/index.ts`
|
||||||
|
- Mobile Clients nutzen `Authorization: Bearer <token>` Header
|
||||||
|
- Ohne `bearer` Plugin: `getSession()` gibt `null` zurück für alle Mobile Requests — stiller Auth-Fehler, alle Requests landen als 401
|
||||||
|
|
||||||
|
### Expo Router — Redirect Pattern (FINAL)
|
||||||
|
**FALSCH:** Session-Guards in mehreren Layouts gleichzeitig → Ping-Pong Loop zwischen `(auth)` und `(app)` Layout
|
||||||
|
|
||||||
|
**RICHTIG:**
|
||||||
|
- `index.tsx`: statischer `<Redirect href="/(auth)/login" />` — kein useSession, kein useEffect
|
||||||
|
- `(auth)/_layout.tsx`: **KEIN Guard**, nur Stack-Definition
|
||||||
|
- `(app)/_layout.tsx`: **EINZIGER Guard**
|
||||||
|
- kein session → `/(auth)/login`
|
||||||
|
- kein householdId → `/(auth)/onboarding`
|
||||||
|
- sonst: render children
|
||||||
|
|
||||||
|
Ein Guard, eine Quelle der Wahrheit.
|
||||||
|
|
||||||
|
### Household / Organization Bridge
|
||||||
|
- `households.id === Better Auth organization.id` (gleiche UUID)
|
||||||
|
- Reihenfolge beim Onboarding:
|
||||||
|
1. `organization.create()` → `organizationId`
|
||||||
|
2. `INSERT INTO households { id: organizationId, ... }` (via `/api/households/setup`)
|
||||||
|
3. `seedDefaultCategories(organizationId)`
|
||||||
|
- Kein harter DB-FK von `households` zu `organization` — application-level check reicht, Better Auth Schema ist extern
|
||||||
|
|
||||||
|
## Projektstand
|
||||||
|
|
||||||
|
| Phase | Status | Details |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| Phase 1 — Foundation | ✅ | Monorepo, DB Schema, Shared Types, Stubs |
|
||||||
|
| Phase 2 — Auth Flow | ✅ | Better Auth, Organization Plugin, Apple, Auth Screens |
|
||||||
|
| Phase 3 — Transactions Full Stack | ✅ | 9 API Tests, 7 Mobile Tests, Tenant Isolation bestätigt |
|
||||||
|
| Phase 4 — Dashboard | ⬜ | |
|
||||||
|
| Phase 5 — Urlaubsbudget | ⬜ | |
|
||||||
|
| Phase 6 — OCR Scanner | ⬜ | |
|
||||||
|
| Phase 7 — Einkaufsliste (WebSockets) | ⬜ | |
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
Projekt-spezifische Skills unter `apps/api/.agents/skills/` (noch leer, wird befüllt).
|
||||||
|
|
||||||
|
@apps/api/.agents/skills/
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM oven/bun:1-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package.json ./
|
||||||
|
COPY packages/db/package.json ./packages/db/
|
||||||
|
COPY packages/auth/package.json ./packages/auth/
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
COPY packages/env/package.json ./packages/env/
|
||||||
|
COPY packages/config/package.json ./packages/config/
|
||||||
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
|
COPY apps/server/package.json ./apps/server/
|
||||||
|
|
||||||
|
COPY bun.lock ./
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY packages/db/ ./packages/db/
|
||||||
|
COPY packages/auth/ ./packages/auth/
|
||||||
|
COPY packages/shared/ ./packages/shared/
|
||||||
|
COPY packages/env/ ./packages/env/
|
||||||
|
COPY packages/config/ ./packages/config/
|
||||||
|
COPY apps/server/ ./apps/server/
|
||||||
|
|
||||||
|
WORKDIR /app/apps/server
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["bun", "run", "src/index.ts"]
|
||||||
2
apps/native/.env.example
Normal file
2
apps/native/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
EXPO_PUBLIC_SERVER_URL=http://localhost:3000
|
||||||
|
EXPO_PUBLIC_WS_URL=ws://localhost:3000
|
||||||
6
apps/native/.gitignore
vendored
6
apps/native/.gitignore
vendored
@@ -19,3 +19,9 @@ web-build/
|
|||||||
# UniWind generated types
|
# UniWind generated types
|
||||||
uniwind-types.d.ts
|
uniwind-types.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
2
apps/native/.npmrc
Normal file
2
apps/native/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
|
workspaces=false
|
||||||
@@ -6,12 +6,36 @@
|
|||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro"
|
"bundler": "metro"
|
||||||
},
|
},
|
||||||
"name": "haushaltsApp",
|
"name": "HausApp",
|
||||||
"slug": "haushaltsApp",
|
"slug": "hausapp",
|
||||||
"plugins": ["expo-font"],
|
"icon": "./assets/icon.png",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-font",
|
||||||
|
"expo-apple-authentication",
|
||||||
|
["expo-camera", {
|
||||||
|
"cameraPermission": "HausApp benötigt die Kamera um Kassenbons zu scannen."
|
||||||
|
}]
|
||||||
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
"reactCompiler": true
|
"reactCompiler": false
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"bundleIdentifier": "com.codingruo.hausapp",
|
||||||
|
"supportsTablet": false,
|
||||||
|
"deploymentTarget": "15.1"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.codingruo.hausapp",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
121
apps/native/app/(app)/_layout.tsx
Normal file
121
apps/native/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useSession, authClient } from "@/src/lib/auth-client";
|
||||||
|
import { useAuthStore } from "@/src/stores/auth.store";
|
||||||
|
import { TAB_COLORS } from "@/src/constants/colors";
|
||||||
|
import { apiRequest } from "@/src/lib/api-client";
|
||||||
|
import { Redirect, Tabs, useRouter } from "expo-router";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
import { queryClient } from "@/src/lib/query-client";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function PendingInvitationHandler() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pendingInvitationId = useAuthStore((s) => s.pendingInvitationId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingInvitationId) return;
|
||||||
|
|
||||||
|
authClient.organization.acceptInvitation({ invitationId: pendingInvitationId })
|
||||||
|
.then(async (result) => {
|
||||||
|
useAuthStore.getState().setPendingInvitationId(null);
|
||||||
|
if (result.error) {
|
||||||
|
Alert.alert("Fehler", result.error.message ?? "Einladung konnte nicht angenommen werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const householdsResponse = await apiRequest<{ households: { id: string; name: string; role: string }[] }>("/api/households");
|
||||||
|
const newHouseholds = householdsResponse.households;
|
||||||
|
useAuthStore.getState().setHouseholds(newHouseholds);
|
||||||
|
if (newHouseholds[0] && !useAuthStore.getState().activeHouseholdId) {
|
||||||
|
useAuthStore.getState().setActiveHousehold(newHouseholds[0].id);
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
Alert.alert("Einladung angenommen", "Du bist jetzt Mitglied des Haushalts.");
|
||||||
|
router.replace("/(app)/haushalt");
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
useAuthStore.getState().setPendingInvitationId(null);
|
||||||
|
Alert.alert("Fehler", err.message ?? "Einladung konnte nicht angenommen werden.");
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pendingInvitationId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
const households = useAuthStore((s) => s.households);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isPending) return null;
|
||||||
|
if (!session) return <Redirect href="/(auth)/login" />;
|
||||||
|
if (households.length === 0) return <Redirect href="/(auth)/onboarding" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PendingInvitationHandler />
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarInactiveTintColor: "#9ca3af",
|
||||||
|
headerShown: false,
|
||||||
|
tabBarStyle: { borderTopColor: "#f3f4f6" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="haushalt/index"
|
||||||
|
options={{
|
||||||
|
title: t('tabs.household'),
|
||||||
|
tabBarActiveTintColor: TAB_COLORS.household,
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="ich/index"
|
||||||
|
options={{
|
||||||
|
title: t('tabs.me'),
|
||||||
|
tabBarActiveTintColor: TAB_COLORS.private,
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="kinder/index"
|
||||||
|
options={{
|
||||||
|
title: t('tabs.children'),
|
||||||
|
tabBarActiveTintColor: TAB_COLORS.children,
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="happy-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="shopping-list/index"
|
||||||
|
options={{
|
||||||
|
title: t('tabs.shopping'),
|
||||||
|
tabBarActiveTintColor: TAB_COLORS.shopping,
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="cart-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="mehr/index"
|
||||||
|
options={{
|
||||||
|
title: t('tabs.more'),
|
||||||
|
tabBarActiveTintColor: TAB_COLORS.more,
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="grid-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hidden — not in tab bar */}
|
||||||
|
<Tabs.Screen name="dashboard/index" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="transactions/index" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="urlaub/index" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="urlaub/[id]" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="settings/index" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="settings/categories" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="settings/fixed-costs" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="settings/transfer-line-items" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="settings/household" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="months/close" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="scanner" options={{ href: null }} />
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/native/app/(app)/dashboard/index.tsx
Normal file
10
apps/native/app/(app)/dashboard/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { PlaceholderScreen } from "@/src/components/features/PlaceholderScreen";
|
||||||
|
|
||||||
|
export default function DashboardScreen() {
|
||||||
|
return (
|
||||||
|
<PlaceholderScreen
|
||||||
|
title="Dashboard"
|
||||||
|
description="Your financial overview will appear here"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
647
apps/native/app/(app)/haushalt/index.tsx
Normal file
647
apps/native/app/(app)/haushalt/index.tsx
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
import { QuickAddModal } from "@/src/components/features/transactions/QuickAddModal";
|
||||||
|
import { TransactionItem } from "@/src/components/features/transactions/TransactionItem";
|
||||||
|
import { EditTransactionModal } from "@/src/components/features/transactions/EditTransactionModal";
|
||||||
|
import { CarryOverBanner } from "@/src/components/features/transactions/CarryOverBanner";
|
||||||
|
import { MonthSummaryHeader } from "@/src/components/features/transactions/MonthSummaryHeader";
|
||||||
|
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { EmptyState } from "@/src/components/ui/EmptyState";
|
||||||
|
import { Numpad } from "@/src/components/ui/Numpad";
|
||||||
|
import { useTransactions, useActivateFixed, useMonthBalance, useDeleteTransaction } from "@/src/hooks/useTransactions";
|
||||||
|
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||||
|
import { useAuthStore } from "@/src/stores/auth.store";
|
||||||
|
import { useSettlementV2, useCreateMonthlyTransfer, useNettoMonth, type MonthlyTransfer } from "@/src/hooks/useFixedCosts";
|
||||||
|
import { useHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||||
|
import { useMonthStatus } from "@/src/hooks/useMonthStatus";
|
||||||
|
import { currentMonthStr, addMonths, monthLabel, monthDateRange } from "@/src/utils/date";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Category } from "@/src/hooks/useCategories";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { TAB_COLORS } from "@/src/constants/colors";
|
||||||
|
|
||||||
|
const ACCENT = TAB_COLORS.household;
|
||||||
|
|
||||||
|
// ── Record Transfer Modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RecordTransferModal({
|
||||||
|
month,
|
||||||
|
toUserId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
toUserId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [amountStr, setAmountStr] = useState("0");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate: createTransfer, isPending } = useCreateMonthlyTransfer();
|
||||||
|
|
||||||
|
function handleNumpad(key: string) {
|
||||||
|
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const amount = parseAmountStr(amountStr);
|
||||||
|
if (!amount || amount <= 0) return;
|
||||||
|
createTransfer(
|
||||||
|
{ month, toUserId, amount, note: note.trim() || undefined },
|
||||||
|
{ onSuccess: onClose },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = parseAmountStr(amountStr) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<ModalHeader
|
||||||
|
title={t('household.settlement.recordTransfer')}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t('household.settlement.book')}
|
||||||
|
saveDisabled={!canSave}
|
||||||
|
saveLoading={isPending}
|
||||||
|
saveColor={ACCENT}
|
||||||
|
/>
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||||
|
<Text className="text-sm text-gray-400 mt-1">{t('household.settlement.transferAmount')}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="px-4 mb-4">
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('household.settlement.notePlaceholder')}
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Numpad onKeyPress={handleNumpad} />
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settlement Banner V2 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SettlementBanner({ month, isCurrent }: { month: string; isCurrent: boolean }) {
|
||||||
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: settlement, isLoading } = useSettlementV2(month);
|
||||||
|
const { data: hhSettings } = useHouseholdSettings();
|
||||||
|
const { data: monthStatus } = useMonthStatus(month);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [showTransferModal, setShowTransferModal] = useState(false);
|
||||||
|
const isClosed = monthStatus?.status === "closed";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="mx-4 mt-4 mb-1 rounded-2xl bg-gray-100 p-4 items-center">
|
||||||
|
<ActivityIndicator size="small" color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settlement || settlement.memberCount <= 1) return null;
|
||||||
|
|
||||||
|
// Closed month — show lock banner
|
||||||
|
if (isClosed && monthStatus) {
|
||||||
|
const closedDate = monthStatus.closedAt
|
||||||
|
? new Date(monthStatus.closedAt).toLocaleDateString("de-DE", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mx-4 mt-4 mb-1 rounded-2xl px-4 py-3 flex-row items-center gap-3"
|
||||||
|
style={{ backgroundColor: "#f0fdf4", borderWidth: 1, borderColor: "#bbf7d0" }}
|
||||||
|
>
|
||||||
|
<Ionicons name="lock-closed" size={18} color="#16a34a" />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-xs text-gray-400">{t('household.settlement.closed')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-green-700">
|
||||||
|
✓ {closedDate}
|
||||||
|
{monthStatus.finalAmount != null && monthStatus.finalAmount > 0
|
||||||
|
? ` · ${formatEur(monthStatus.finalAmount)}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = settlement.remaining;
|
||||||
|
const isOwing = remaining > 0.005;
|
||||||
|
const isReceiving = remaining < -0.005;
|
||||||
|
const isEven = !isOwing && !isReceiving;
|
||||||
|
|
||||||
|
const bannerBg = isOwing ? "#fff7ed" : isReceiving ? "#f0fdf4" : "#f9fafb";
|
||||||
|
const bannerBorder = isOwing ? "#fed7aa" : isReceiving ? "#bbf7d0" : "#e5e7eb";
|
||||||
|
const amountColor = isOwing ? "#ea580c" : isReceiving ? "#16a34a" : "#6b7280";
|
||||||
|
|
||||||
|
const others = settlement.members.filter((m) => m.userId !== userId);
|
||||||
|
const otherName = hhSettings?.partnerName ?? others[0]?.name ?? "den anderen";
|
||||||
|
const otherUserId = others[0]?.userId ?? "";
|
||||||
|
|
||||||
|
let mainText = t('household.settlement.allSettled');
|
||||||
|
if (isOwing) mainText = t('household.settlement.youOwe', { name: otherName });
|
||||||
|
else if (isReceiving) mainText = t('household.settlement.theyOwe', { name: otherName });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={{ backgroundColor: bannerBg, borderColor: bannerBorder, borderWidth: 1 }}
|
||||||
|
className="mx-4 mt-4 mb-1 rounded-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Summary row */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setExpanded((v) => !v)}
|
||||||
|
className="flex-row items-center px-4 py-3 active:opacity-80"
|
||||||
|
>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-xs text-gray-400 mb-0.5">{t('household.settlement.monthlySettlement')}</Text>
|
||||||
|
<Text className="text-sm font-medium" style={{ color: amountColor }}>{mainText}</Text>
|
||||||
|
{!isEven && (
|
||||||
|
<Text className="text-3xl font-bold" style={{ color: amountColor }}>
|
||||||
|
{formatEur(Math.abs(remaining))}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{isEven && (
|
||||||
|
<Text className="text-base font-bold text-green-600">{t('household.settlement.allSettled')}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Expandable detail */}
|
||||||
|
{expanded && (
|
||||||
|
<View style={{ backgroundColor: "rgba(0,0,0,0.03)" }} className="px-4 pb-4">
|
||||||
|
{/* Haushalt breakdown */}
|
||||||
|
<View className="py-2 gap-1.5">
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-xs text-gray-500">{t('household.settlement.householdExpenses')}</Text>
|
||||||
|
<Text className="text-xs font-medium text-gray-700">{formatEur(settlement.householdExpenses)}</Text>
|
||||||
|
</View>
|
||||||
|
{settlement.householdIncome > 0 && (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-xs text-gray-500">{t('household.settlement.householdIncome')}</Text>
|
||||||
|
<Text className="text-xs font-medium text-green-600">−{formatEur(settlement.householdIncome)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-xs font-semibold text-gray-600">{t('household.settlement.yourShare', { percent: settlement.userSharePercent ?? 50 })}</Text>
|
||||||
|
<Text className="text-xs font-semibold text-gray-800">{formatEur(settlement.perMemberShare)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Who paid what */}
|
||||||
|
<View className="h-px bg-gray-200 my-2" />
|
||||||
|
<View className="gap-1.5">
|
||||||
|
{settlement.members.map((mem) => {
|
||||||
|
const isMe = mem.userId === userId;
|
||||||
|
const name = isMe ? "Du" : otherName;
|
||||||
|
const paidAmount = isMe ? settlement.myOwnExpenses : (settlement.householdExpenses - settlement.myOwnExpenses);
|
||||||
|
if (paidAmount < 0.01) return null;
|
||||||
|
return (
|
||||||
|
<View key={mem.userId} className="flex-row justify-between">
|
||||||
|
<Text className="text-xs text-gray-500">{t('household.settlement.paidBy', { name })}</Text>
|
||||||
|
<Text className="text-xs font-medium text-gray-700">{formatEur(paidAmount)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fixed transfer items — summarised */}
|
||||||
|
{settlement.lineItemsTotal > 0 && (
|
||||||
|
<>
|
||||||
|
<View className="h-px bg-gray-200 my-2" />
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-xs text-gray-500">+ {t('household.settlement.fixedTransfers')}</Text>
|
||||||
|
<Text className="text-xs font-medium text-gray-700">{formatEur(settlement.lineItemsTotal)}</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Total owed */}
|
||||||
|
<View className="h-px bg-gray-200 my-2" />
|
||||||
|
<View className="flex-row justify-between mb-3">
|
||||||
|
<Text className="text-xs font-bold text-gray-700">{t('household.settlement.toTransfer')}</Text>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: amountColor }}>{formatEur(settlement.totalOwed)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Already transferred */}
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs text-gray-400">{t('household.settlement.alreadyTransferred')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-gray-700">{formatEur(settlement.alreadyTransferred)}</Text>
|
||||||
|
</View>
|
||||||
|
{isOwing && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowTransferModal(true)}
|
||||||
|
style={{ backgroundColor: ACCENT }}
|
||||||
|
className="px-4 py-2 rounded-xl active:opacity-80"
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-semibold text-white">+ {t('household.settlement.book')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Close month button */}
|
||||||
|
{isCurrent && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push({ pathname: "/(app)/months/close", params: { month } })}
|
||||||
|
className="mt-3 flex-row items-center justify-center gap-2 py-2.5 rounded-xl active:opacity-80"
|
||||||
|
style={{ backgroundColor: "#f3f4f6", borderWidth: 1, borderColor: "#e5e7eb" }}
|
||||||
|
>
|
||||||
|
<Ionicons name="lock-closed-outline" size={14} color="#6b7280" />
|
||||||
|
<Text className="text-xs font-semibold text-gray-600">{t('household.settlement.closeMonth')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfer history */}
|
||||||
|
{settlement.transfers.length > 0 && (
|
||||||
|
<View className="mt-3 gap-1">
|
||||||
|
{settlement.transfers.map((t: MonthlyTransfer) => (
|
||||||
|
<View key={t.id} className="flex-row justify-between">
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString("de-DE", { day: "numeric", month: "short" })}
|
||||||
|
{t.note ? ` · ${t.note}` : ""}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs font-medium text-gray-600">{formatEur(t.amount)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showTransferModal && (
|
||||||
|
<RecordTransferModal
|
||||||
|
month={month}
|
||||||
|
toUserId={otherUserId}
|
||||||
|
onClose={() => setShowTransferModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Netto Card ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NettoCard({ month }: { month: string }) {
|
||||||
|
const { data, isLoading } = useNettoMonth(month);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="mx-4 mt-2 mb-1 rounded-2xl bg-white p-4 items-center" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||||
|
<ActivityIndicator size="small" color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const isPositive = data.netto >= 0;
|
||||||
|
const nettoColor = isPositive ? "#16a34a" : "#dc2626";
|
||||||
|
const nettoIcon = isPositive ? "trending-up" : "trending-down";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mx-4 mt-2 mb-1 rounded-2xl bg-white overflow-hidden"
|
||||||
|
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setExpanded((v) => !v)}
|
||||||
|
className="flex-row items-center px-4 py-3 active:opacity-80"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="w-9 h-9 rounded-xl items-center justify-center mr-3"
|
||||||
|
style={{ backgroundColor: isPositive ? "#dcfce7" : "#fee2e2" }}
|
||||||
|
>
|
||||||
|
<Ionicons name={nettoIcon as React.ComponentProps<typeof Ionicons>["name"]} size={18} color={nettoColor} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-xs text-gray-400">{t('household.nettoMonth')}</Text>
|
||||||
|
<Text className="text-xl font-bold" style={{ color: nettoColor }}>
|
||||||
|
{isPositive ? "+" : "−"}{formatEur(Math.abs(data.netto))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-end mr-2">
|
||||||
|
<Text className="text-xs text-gray-400">{t('household.income')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-green-600">+{formatEur(data.totalIncome)}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<View className="px-4 pb-4" style={{ backgroundColor: "rgba(0,0,0,0.02)" }}>
|
||||||
|
{/* Income breakdown */}
|
||||||
|
{data.incomeByScope.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text className="text-xs font-medium text-gray-400 mb-2">Einnahmen nach Bereich</Text>
|
||||||
|
{data.incomeByScope.map((s) => (
|
||||||
|
<View key={s.scope} className="flex-row justify-between mb-1">
|
||||||
|
<Text className="text-xs text-gray-500">{s.label}</Text>
|
||||||
|
<Text className="text-xs font-medium text-green-600">+{formatEur(s.amount)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View className="h-px bg-gray-100 my-2" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className="text-xs text-gray-400 mb-2">Keine Einnahmen gebucht</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expenses */}
|
||||||
|
<View className="flex-row justify-between mb-1">
|
||||||
|
<Text className="text-xs text-gray-500">Ausgaben (alle Bereiche)</Text>
|
||||||
|
<Text className="text-xs font-medium text-red-500">−{formatEur(data.totalExpenses)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="h-px bg-gray-100 my-2" />
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-xs font-bold text-gray-700">Netto</Text>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: nettoColor }}>
|
||||||
|
{isPositive ? "+" : "−"}{formatEur(Math.abs(data.netto))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Month Switcher ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MonthSwitcher({
|
||||||
|
month,
|
||||||
|
isLocked,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
isLocked: boolean;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
|
const isCurrent = month === currentMonthStr();
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center justify-center gap-4 py-3">
|
||||||
|
<Pressable onPress={onPrev} className="p-1 active:opacity-50">
|
||||||
|
<Ionicons name="chevron-back" size={18} color="#6b7280" />
|
||||||
|
</Pressable>
|
||||||
|
<View className="flex-row items-center gap-1.5 w-32 justify-center">
|
||||||
|
{isLocked && <Ionicons name="lock-closed" size={12} color="#9ca3af" />}
|
||||||
|
<Text className="text-sm font-semibold text-gray-800 text-center">
|
||||||
|
{monthLabel(month)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={onNext}
|
||||||
|
disabled={isCurrent}
|
||||||
|
className="p-1 active:opacity-50"
|
||||||
|
style={{ opacity: isCurrent ? 0.3 : 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#6b7280" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Screen ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FilterType = "all" | "income" | "expense";
|
||||||
|
|
||||||
|
export default function HaushaltScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const [month, setMonth] = useState(currentMonthStr());
|
||||||
|
const [filter, setFilter] = useState<FilterType>("all");
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [showFabMenu, setShowFabMenu] = useState(false);
|
||||||
|
const [showAddCategory, setShowAddCategory] = useState(false);
|
||||||
|
const [addCategoryType, setAddCategoryType] = useState<"expense" | "income">("expense");
|
||||||
|
const [newCategory, setNewCategory] = useState<Category | null>(null);
|
||||||
|
const [editTransaction, setEditTransaction] = useState<TransactionWithCategory | null>(null);
|
||||||
|
const { mutate: deleteTransaction } = useDeleteTransaction();
|
||||||
|
const { data: monthStatus } = useMonthStatus(month);
|
||||||
|
const isLocked = monthStatus?.status === "closed";
|
||||||
|
|
||||||
|
const isCurrent = month === currentMonthStr();
|
||||||
|
const { mutate: activateFixed } = useActivateFixed();
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCurrent) {
|
||||||
|
activateFixed({ month, scope: "household" });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
const [fromDate, toDate] = monthDateRange(month);
|
||||||
|
|
||||||
|
const txFilter = {
|
||||||
|
scope: "household" as const,
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate,
|
||||||
|
...(filter !== "all" ? { type: filter as "income" | "expense" } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: transactions = [], isLoading, refetch, isRefetching } = useTransactions(txFilter);
|
||||||
|
const { data: balance, isLoading: balanceLoading } = useMonthBalance("household", month);
|
||||||
|
|
||||||
|
function renderEmpty() {
|
||||||
|
if (isLoading) return null;
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="wallet-outline"
|
||||||
|
title={t('household.noTransactions')}
|
||||||
|
subtitle={t('household.noTransactionsHint')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ backgroundColor: "#fff", paddingTop: insets.top, borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}>
|
||||||
|
<MonthSwitcher
|
||||||
|
month={month}
|
||||||
|
isLocked={isLocked}
|
||||||
|
onPrev={() => setMonth((m) => addMonths(m, -1))}
|
||||||
|
onNext={() => setMonth((m) => addMonths(m, 1))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={transactions}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View className="bg-white">
|
||||||
|
<TransactionItem
|
||||||
|
transaction={item}
|
||||||
|
onPress={isLocked ? () => {} : setEditTransaction}
|
||||||
|
onDelete={isLocked ? () => {} : (t) => deleteTransaction(t.id)}
|
||||||
|
locked={isLocked}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View className="bg-gray-50">
|
||||||
|
<SettlementBanner month={month} isCurrent={isCurrent} />
|
||||||
|
<NettoCard month={month} />
|
||||||
|
|
||||||
|
<MonthSummaryHeader
|
||||||
|
income={balance?.income}
|
||||||
|
expense={balance?.expense}
|
||||||
|
balance={balance?.balance}
|
||||||
|
isLoading={balanceLoading}
|
||||||
|
accentColor={ACCENT}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CarryOverBanner month={month} scope="household" />
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<View className="flex-row px-4 py-3 gap-2 bg-white border-b border-gray-100 mt-3">
|
||||||
|
{(["all", "expense", "income"] as const).map((f) => (
|
||||||
|
<Pressable
|
||||||
|
key={f}
|
||||||
|
onPress={() => setFilter(f)}
|
||||||
|
style={{ backgroundColor: filter === f ? ACCENT : "#f3f4f6" }}
|
||||||
|
className="px-4 py-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: filter === f ? "#fff" : "#4b5563" }}
|
||||||
|
>
|
||||||
|
{f === "all" ? t('household.all') : f === "expense" ? t('household.expenses') : t('household.income')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefetching}
|
||||||
|
onRefresh={() => void refetch()}
|
||||||
|
tintColor={ACCENT}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-px bg-gray-50 ml-16" />}
|
||||||
|
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<View className="absolute inset-0 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB — hidden for locked months */}
|
||||||
|
{!isLocked && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
{showFabMenu && (
|
||||||
|
<Pressable
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.25)" }}
|
||||||
|
onPress={() => setShowFabMenu(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB menu — anchored above FAB, zIndex above backdrop */}
|
||||||
|
{showFabMenu && (
|
||||||
|
<View
|
||||||
|
className="absolute right-6 bg-white rounded-2xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
bottom: insets.bottom + 80,
|
||||||
|
minWidth: 200,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 16,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
elevation: 12,
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setShowFabMenu(false);
|
||||||
|
setShowAddModal(true);
|
||||||
|
}}
|
||||||
|
className="flex-row items-center gap-3 px-5 py-4 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Ionicons name="pencil-outline" size={20} color={ACCENT} />
|
||||||
|
<Text className="text-sm font-medium text-gray-800">
|
||||||
|
{t("scanner.manualEntry")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<View className="h-px bg-gray-100" />
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setShowFabMenu(false);
|
||||||
|
router.push("/(app)/scanner");
|
||||||
|
}}
|
||||||
|
className="flex-row items-center gap-3 px-5 py-4 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Ionicons name="camera-outline" size={20} color={ACCENT} />
|
||||||
|
<Text className="text-sm font-medium text-gray-800">
|
||||||
|
{t("scanner.scanReceipt")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowFabMenu((v) => !v)}
|
||||||
|
style={{ backgroundColor: ACCENT, bottom: insets.bottom + 20, zIndex: 101 }}
|
||||||
|
className="absolute right-6 w-14 h-14 rounded-full items-center justify-center shadow-lg active:opacity-80"
|
||||||
|
>
|
||||||
|
<Ionicons name={showFabMenu ? "close" : "add"} size={28} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<QuickAddModal
|
||||||
|
visible={showAddModal}
|
||||||
|
onClose={() => { setShowAddModal(false); setNewCategory(null); }}
|
||||||
|
onRequestAddCategory={(t) => { setAddCategoryType(t); setShowAddModal(false); setShowAddCategory(true); }}
|
||||||
|
newCategory={newCategory}
|
||||||
|
defaultScope="household"
|
||||||
|
/>
|
||||||
|
<AddCategoryModal
|
||||||
|
visible={showAddCategory}
|
||||||
|
onClose={() => { setShowAddCategory(false); setShowAddModal(true); }}
|
||||||
|
defaultType={addCategoryType}
|
||||||
|
onCreated={(cat) => { setNewCategory(cat); setShowAddCategory(false); setShowAddModal(true); }}
|
||||||
|
/>
|
||||||
|
{editTransaction && (
|
||||||
|
<EditTransactionModal
|
||||||
|
transaction={editTransaction}
|
||||||
|
onClose={() => setEditTransaction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/native/app/(app)/ich/index.tsx
Normal file
23
apps/native/app/(app)/ich/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { TransactionScreen } from "@/src/components/features/transactions/TransactionScreen";
|
||||||
|
import { DebtsSection } from "@/src/components/features/debts/DebtsSection";
|
||||||
|
import { ClaimsSection } from "@/src/components/features/debts/ClaimsSection";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function IchScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<TransactionScreen
|
||||||
|
scope="private"
|
||||||
|
accentColor="#7c3aed"
|
||||||
|
emptyTitle={t('me.noTransactions')}
|
||||||
|
emptySubtitle={t('me.noTransactionsHint')}
|
||||||
|
headerExtra={
|
||||||
|
<View>
|
||||||
|
<DebtsSection />
|
||||||
|
<ClaimsSection />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
apps/native/app/(app)/kinder/index.tsx
Normal file
234
apps/native/app/(app)/kinder/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { TransactionScreen } from "@/src/components/features/transactions/TransactionScreen";
|
||||||
|
import { useChildren, useCreateChild, type Child } from "@/src/hooks/useChildren";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
|
||||||
|
const CHILD_COLORS = [
|
||||||
|
"#ec4899",
|
||||||
|
"#f59e0b",
|
||||||
|
"#10b981",
|
||||||
|
"#2563EB",
|
||||||
|
"#7c3aed",
|
||||||
|
"#ef4444",
|
||||||
|
"#0ea5e9",
|
||||||
|
"#378ADD",
|
||||||
|
];
|
||||||
|
|
||||||
|
function AddChildModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (child: Child) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [color, setColor] = useState(CHILD_COLORS[0]!);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate: createChild, isPending } = useCreateChild();
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
createChild(
|
||||||
|
{ name: trimmed, color },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
onCreated(data.child);
|
||||||
|
setName("");
|
||||||
|
setColor(CHILD_COLORS[0]!);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setName("");
|
||||||
|
setColor(CHILD_COLORS[0]!);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={handleClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<ModalHeader
|
||||||
|
title={t('children.addChild')}
|
||||||
|
onClose={handleClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t('common.save')}
|
||||||
|
saveDisabled={!name.trim()}
|
||||||
|
saveLoading={isPending}
|
||||||
|
saveColor="#ec4899"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="px-4 mt-6">
|
||||||
|
{/* Name Input */}
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">Name</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-6"
|
||||||
|
placeholder="z.B. Emma"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-3">Farbe</Text>
|
||||||
|
<View className="flex-row flex-wrap gap-3">
|
||||||
|
{CHILD_COLORS.map((c) => (
|
||||||
|
<Pressable
|
||||||
|
key={c}
|
||||||
|
onPress={() => setColor(c)}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: c,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: color === c ? 3 : 0,
|
||||||
|
borderColor: "#fff",
|
||||||
|
shadowColor: color === c ? c : "transparent",
|
||||||
|
shadowOpacity: color === c ? 0.5 : 0,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: color === c ? 4 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{color === c && (
|
||||||
|
<Ionicons name="checkmark" size={20} color="#fff" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KinderScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: children = [], isLoading } = useChildren();
|
||||||
|
const [activeChildId, setActiveChildId] = useState<string | null>(null);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
|
||||||
|
// Determine active child — fall back to first child when list loads
|
||||||
|
const activeChild =
|
||||||
|
children.find((c) => c.id === activeChildId) ?? children[0] ?? null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#ec4899" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
{/* Empty State */}
|
||||||
|
{children.length === 0 && (
|
||||||
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
|
<Ionicons name="happy-outline" size={72} color="#d1d5db" style={{ marginBottom: 16 }} />
|
||||||
|
<Text className="text-lg font-semibold text-gray-700 mb-2 text-center">
|
||||||
|
{t('children.noChildren')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-400 text-center mb-8">
|
||||||
|
{t('children.noChildrenHint')}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAddModal(true)}
|
||||||
|
className="px-6 py-3 rounded-full items-center justify-center"
|
||||||
|
style={{ backgroundColor: "#ec4899" }}
|
||||||
|
>
|
||||||
|
<Text className="text-white font-semibold text-base">+ {t('children.addChild')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Children Tab Switcher + Content */}
|
||||||
|
{children.length > 0 && activeChild && (
|
||||||
|
<TransactionScreen
|
||||||
|
scope="child"
|
||||||
|
childId={activeChild.id}
|
||||||
|
accentColor={activeChild.color}
|
||||||
|
emptyTitle={t('children.noTransactions', { name: activeChild.name })}
|
||||||
|
emptySubtitle={t('children.noTransactionsHint')}
|
||||||
|
headerExtra={
|
||||||
|
<View className="bg-white border-b border-gray-100">
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 10, gap: 8, flexDirection: "row", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
{children.map((child) => {
|
||||||
|
const isActive = child.id === activeChild.id;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={child.id}
|
||||||
|
onPress={() => setActiveChildId(child.id)}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: isActive ? child.color : "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: isActive ? "#fff" : "#4b5563",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add Child Button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAddModal(true)}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#fce7f3",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={20} color="#ec4899" />
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddChildModal
|
||||||
|
visible={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onCreated={(child) => setActiveChildId(child.id)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/native/app/(app)/mehr/index.tsx
Normal file
68
apps/native/app/(app)/mehr/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function MehrScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
color: string;
|
||||||
|
route: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
|
{
|
||||||
|
icon: "airplane-outline",
|
||||||
|
label: t('mehr.vacation'),
|
||||||
|
subtitle: t('mehr.vacationSubtitle'),
|
||||||
|
color: "#0ea5e9",
|
||||||
|
route: "/(app)/urlaub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "settings-outline",
|
||||||
|
label: t('settings.title'),
|
||||||
|
subtitle: t('mehr.settingsSubtitle'),
|
||||||
|
color: "#6b7280",
|
||||||
|
route: "/(app)/settings",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 bg-gray-50"
|
||||||
|
contentContainerStyle={{ paddingTop: insets.top + 16, paddingBottom: insets.bottom + 24 }}
|
||||||
|
>
|
||||||
|
<Text className="text-2xl font-bold text-gray-900 px-4 mb-6">{t('tabs.more')}</Text>
|
||||||
|
|
||||||
|
<View className="mx-4 bg-white rounded-2xl overflow-hidden" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||||
|
{MENU_ITEMS.map((item, index) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.route}
|
||||||
|
onPress={() => router.push(item.route as Parameters<typeof router.push>[0])}
|
||||||
|
className="flex-row items-center px-4 py-4 active:bg-gray-50"
|
||||||
|
style={index < MENU_ITEMS.length - 1 ? { borderBottomWidth: 1, borderBottomColor: "#f3f4f6" } : undefined}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="w-10 h-10 rounded-xl items-center justify-center mr-4"
|
||||||
|
style={{ backgroundColor: `${item.color}18` }}
|
||||||
|
>
|
||||||
|
<Ionicons name={item.icon} size={22} color={item.color} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-semibold text-gray-900">{item.label}</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mt-0.5">{item.subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#d1d5db" />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
apps/native/app/(app)/months/close.tsx
Normal file
243
apps/native/app/(app)/months/close.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useSettlementV2 } from "@/src/hooks/useFixedCosts";
|
||||||
|
import { useHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||||
|
import { useCloseMonth } from "@/src/hooks/useMonthStatus";
|
||||||
|
import { useAuthStore } from "@/src/stores/auth.store";
|
||||||
|
import { monthLabel } from "@/src/utils/date";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
|
||||||
|
const ACCENT = "#2563EB";
|
||||||
|
|
||||||
|
export default function CloseMonthScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { month } = useLocalSearchParams<{ month: string }>();
|
||||||
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
|
const { data: settlement, isLoading: settlementLoading } = useSettlementV2(month);
|
||||||
|
const { data: hhSettings } = useHouseholdSettings();
|
||||||
|
const { mutate: closeMonth, isPending } = useCloseMonth(month);
|
||||||
|
|
||||||
|
const remaining = settlement?.remaining ?? 0;
|
||||||
|
const [amountStr, setAmountStr] = useState<string | null>(null);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
// Lazy-init amount from settlement once loaded
|
||||||
|
const displayAmount = amountStr ?? (remaining > 0 ? remaining.toFixed(2).replace(".", ",") : "0,00");
|
||||||
|
|
||||||
|
const others = (settlement?.members ?? []).filter((m) => m.userId !== userId);
|
||||||
|
const otherName = hhSettings?.partnerName ?? others[0]?.name ?? "Partner";
|
||||||
|
const otherUserId = others[0]?.userId ?? "";
|
||||||
|
|
||||||
|
function handleAmountChange(text: string) {
|
||||||
|
// Allow only digits and one comma
|
||||||
|
const cleaned = text.replace(/[^0-9,]/g, "");
|
||||||
|
const parts = cleaned.split(",");
|
||||||
|
if (parts.length > 2) return;
|
||||||
|
if (parts[1] !== undefined && parts[1].length > 2) return;
|
||||||
|
setAmountStr(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
const amount = parseFloat(displayAmount.replace(",", ".")) || 0;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t('monthClose.closeConfirmTitle', { month: monthLabel(month) }),
|
||||||
|
t('monthClose.closeConfirmMessage'),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t('monthClose.closeConfirmAction'),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
closeMonth(
|
||||||
|
{ finalAmount: amount, toUserId: otherUserId, notes: notes.trim() || undefined },
|
||||||
|
{
|
||||||
|
onSuccess: () => router.back(),
|
||||||
|
onError: (err) =>
|
||||||
|
Alert.alert(t('common.error'), err.message ?? "Abschluss fehlgeschlagen"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settlementLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = settlement;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
className="bg-white border-b border-gray-100"
|
||||||
|
style={{ paddingTop: insets.top }}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center px-4 py-3">
|
||||||
|
<Pressable onPress={() => router.back()} className="mr-3 p-1">
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-base font-semibold text-gray-900 flex-1">
|
||||||
|
{t('monthClose.title', { month: monthLabel(month) })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 80 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Overview card */}
|
||||||
|
{s && (
|
||||||
|
<View className="bg-white rounded-2xl p-4 mb-4" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('monthClose.overview')}</Text>
|
||||||
|
|
||||||
|
<Row label={t('monthClose.householdTotal')} value={`-${formatEur(s.householdExpenses)}`} />
|
||||||
|
{s.householdIncome > 0 && (
|
||||||
|
<Row label={t('monthClose.householdIncome')} value={`+${formatEur(s.householdIncome)}`} color="#16a34a" />
|
||||||
|
)}
|
||||||
|
<Row
|
||||||
|
label={t('monthClose.yourShare', { percent: s.memberCount > 0 ? Math.round(100 / s.memberCount) : 50 })}
|
||||||
|
value={`-${formatEur(s.perMemberShare)}`}
|
||||||
|
bold
|
||||||
|
/>
|
||||||
|
|
||||||
|
{s.lineItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<View className="h-px bg-gray-100 my-2" />
|
||||||
|
{s.lineItems.map((li) => (
|
||||||
|
<Row key={li.id} label={li.label} value={`-${formatEur(li.amount)}`} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="h-px bg-gray-200 my-3" />
|
||||||
|
<Row label={t('monthClose.totalTransfer')} value={`-${formatEur(s.totalOwed)}`} bold />
|
||||||
|
<Row label={t('monthClose.alreadyTransferred')} value={`-${formatEur(s.alreadyTransferred)}`} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remaining amount hero */}
|
||||||
|
<View
|
||||||
|
className="rounded-2xl p-4 mb-4 items-center"
|
||||||
|
style={{ backgroundColor: remaining > 0.01 ? "#fff7ed" : "#f0fdf4", borderWidth: 1, borderColor: remaining > 0.01 ? "#fed7aa" : "#bbf7d0" }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-gray-500 mb-1">
|
||||||
|
{remaining > 0.01 ? t('monthClose.receives', { name: otherName }) : remaining < -0.01 ? t('monthClose.youReceive') : t('monthClose.settled')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-3xl font-bold"
|
||||||
|
style={{ color: remaining > 0.01 ? "#ea580c" : remaining < -0.01 ? "#16a34a" : "#6b7280" }}
|
||||||
|
>
|
||||||
|
{formatEur(Math.abs(remaining))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Amount adjustment */}
|
||||||
|
<View className="bg-white rounded-2xl p-4 mb-4" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('monthClose.adjustAmount')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mb-3">
|
||||||
|
{t('monthClose.adjustHint')}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3">
|
||||||
|
<Text className="text-base text-gray-400 mr-2">€</Text>
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 text-base text-gray-900"
|
||||||
|
value={displayAmount}
|
||||||
|
onChangeText={handleAmountChange}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
selectTextOnFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<View className="bg-white rounded-2xl p-4 mb-6" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">{t('monthClose.note')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('monthClose.notePlaceholder')}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleClose}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded-2xl py-4 items-center active:opacity-80 mb-3"
|
||||||
|
style={{ backgroundColor: "#dc2626" }}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Ionicons name="lock-closed-outline" size={18} color="#fff" />
|
||||||
|
<Text className="text-base font-semibold text-white">{t('monthClose.closeButton')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.back()}
|
||||||
|
className="py-3 items-center active:opacity-50"
|
||||||
|
>
|
||||||
|
<Text className="text-sm text-gray-400">{t('common.cancel')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
bold,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
bold?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row justify-between items-center py-1.5">
|
||||||
|
<Text className={`text-sm ${bold ? "font-semibold text-gray-800" : "text-gray-500"}`}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className={`text-sm ${bold ? "font-semibold text-gray-800" : "text-gray-700"}`}
|
||||||
|
style={color ? { color } : undefined}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
459
apps/native/app/(app)/scanner.tsx
Normal file
459
apps/native/app/(app)/scanner.tsx
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Linking,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
import { readAsStringAsync, EncodingType } from "expo-file-system/legacy";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@/src/lib/api-client";
|
||||||
|
import { useCategories } from "@/src/hooks/useCategories";
|
||||||
|
import { TAB_COLORS } from "@/src/constants/colors";
|
||||||
|
|
||||||
|
const ACCENT = TAB_COLORS.household;
|
||||||
|
|
||||||
|
type ScreenState = "camera" | "scanning" | "confirm" | "booking";
|
||||||
|
|
||||||
|
type OcrResponse = {
|
||||||
|
amount: number | null;
|
||||||
|
label: string | null;
|
||||||
|
date: string | null;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateTransactionBody = {
|
||||||
|
amount: number;
|
||||||
|
merchant: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
type: "expense";
|
||||||
|
scope: "household" | "private";
|
||||||
|
categoryId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_CATEGORIES = [
|
||||||
|
"Lebensmittel",
|
||||||
|
"Restaurant",
|
||||||
|
"Transport",
|
||||||
|
"Shopping",
|
||||||
|
"Haushalt",
|
||||||
|
"Sonstiges",
|
||||||
|
];
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScannerScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
|
||||||
|
const [screenState, setScreenState] = useState<ScreenState>("camera");
|
||||||
|
const [cameraKey, setCameraKey] = useState(0);
|
||||||
|
|
||||||
|
// Confirm sheet state
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [amountStr, setAmountStr] = useState("");
|
||||||
|
const [date, setDate] = useState(todayIso()); // always YYYY-MM-DD internally
|
||||||
|
const [dateDisplay, setDateDisplay] = useState(() => {
|
||||||
|
const d = todayIso();
|
||||||
|
return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(0, 4)}`;
|
||||||
|
});
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | undefined>(undefined);
|
||||||
|
const [scope, setScope] = useState<"household" | "private">("household");
|
||||||
|
|
||||||
|
const { data: categories = [] } = useCategories();
|
||||||
|
const expenseCategories = categories.filter((c) => c.type === "expense");
|
||||||
|
const displayCategories =
|
||||||
|
expenseCategories.length > 0
|
||||||
|
? expenseCategories.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
: FALLBACK_CATEGORIES.map((name, i) => ({ id: String(i), name }));
|
||||||
|
|
||||||
|
// ── Permission not yet determined ─────────────────────────────────────────
|
||||||
|
if (!permission) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-black items-center justify-center">
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permission denied ─────────────────────────────────────────────────────
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 bg-black items-center justify-center px-8 gap-4"
|
||||||
|
style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
|
||||||
|
>
|
||||||
|
{/* Back button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.back()}
|
||||||
|
className="absolute top-0 left-4 p-3"
|
||||||
|
style={{ top: insets.top }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={26} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Ionicons name="camera-outline" size={64} color="#6b7280" />
|
||||||
|
<Text className="text-white text-center text-base font-medium">
|
||||||
|
{t("scanner.permissionDenied")}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void requestPermission()}
|
||||||
|
style={{ backgroundColor: ACCENT }}
|
||||||
|
className="px-6 py-3 rounded-xl"
|
||||||
|
>
|
||||||
|
<Text className="text-white font-semibold">{t("scanner.openSettings")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void Linking.openSettings()}
|
||||||
|
className="px-6 py-3"
|
||||||
|
>
|
||||||
|
<Text className="text-gray-400 text-sm">{t("scanner.openSettings")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Capture & OCR ─────────────────────────────────────────────────────────
|
||||||
|
async function handleCapture() {
|
||||||
|
if (!cameraRef.current) return;
|
||||||
|
setScreenState("scanning");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.7 });
|
||||||
|
if (!photo?.uri) throw new Error("No photo URI");
|
||||||
|
|
||||||
|
const base64 = await readAsStringAsync(photo.uri, {
|
||||||
|
encoding: EncodingType.Base64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await apiRequest<OcrResponse>("/api/scanner/receipt", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ imageBase64: base64, mimeType: "image/jpeg" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLabel(result.label ?? "");
|
||||||
|
setAmountStr(result.amount != null ? String(result.amount) : "");
|
||||||
|
const isoDate = result.date ?? todayIso();
|
||||||
|
setDate(isoDate);
|
||||||
|
setDateDisplay(`${isoDate.slice(8, 10)}.${isoDate.slice(5, 7)}.${isoDate.slice(0, 4)}`);
|
||||||
|
setScreenState("confirm");
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t("common.error"), t("scanner.error"));
|
||||||
|
setScreenState("camera");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Book transaction ──────────────────────────────────────────────────────
|
||||||
|
async function handleBook() {
|
||||||
|
const amount = parseFloat(amountStr.replace(",", "."));
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
Alert.alert(t("common.error"), t("scanner.notRecognized"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScreenState("booking");
|
||||||
|
try {
|
||||||
|
const body: CreateTransactionBody = {
|
||||||
|
amount,
|
||||||
|
merchant: label.trim() || t("scanner.title"),
|
||||||
|
description: label.trim() || t("scanner.title"),
|
||||||
|
date: new Date(date).toISOString(),
|
||||||
|
type: "expense",
|
||||||
|
scope,
|
||||||
|
...(selectedCategoryId ? { categoryId: selectedCategoryId } : {}),
|
||||||
|
};
|
||||||
|
await apiRequest<unknown>("/api/transactions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
handleRetry(); // reset all state
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t("common.error"), t("scanner.error"));
|
||||||
|
setScreenState("confirm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
const today = todayIso();
|
||||||
|
setScreenState("camera");
|
||||||
|
setLabel("");
|
||||||
|
setAmountStr("");
|
||||||
|
setDate(today);
|
||||||
|
setDateDisplay(`${today.slice(8, 10)}.${today.slice(5, 7)}.${today.slice(0, 4)}`);
|
||||||
|
setSelectedCategoryId(undefined);
|
||||||
|
setScope("household");
|
||||||
|
setCameraKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-black">
|
||||||
|
{/* Camera */}
|
||||||
|
<CameraView
|
||||||
|
key={cameraKey}
|
||||||
|
ref={cameraRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
facing="back"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header overlay */}
|
||||||
|
<View
|
||||||
|
className="absolute left-0 right-0 flex-row items-center px-4"
|
||||||
|
style={{ top: insets.top, paddingTop: 8 }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.back()}
|
||||||
|
className="w-10 h-10 rounded-full bg-black/40 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="flex-1 text-center text-white font-semibold text-base mr-10">
|
||||||
|
{t("scanner.title")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hint text */}
|
||||||
|
{screenState === "camera" && (
|
||||||
|
<View
|
||||||
|
className="absolute left-0 right-0 items-center"
|
||||||
|
style={{ top: insets.top + 64 }}
|
||||||
|
>
|
||||||
|
<View className="bg-black/40 px-4 py-2 rounded-full">
|
||||||
|
<Text className="text-white text-sm">{t("scanner.hint")}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Viewfinder frame */}
|
||||||
|
{screenState === "camera" && (
|
||||||
|
<View className="absolute inset-0 items-center justify-center">
|
||||||
|
<View
|
||||||
|
className="border-2 border-white/60 rounded-2xl"
|
||||||
|
style={{ width: "85%", height: "75%" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Capture button */}
|
||||||
|
{screenState === "camera" && (
|
||||||
|
<View
|
||||||
|
className="absolute left-0 right-0 items-center"
|
||||||
|
style={{ bottom: insets.bottom + 40 }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void handleCapture()}
|
||||||
|
className="w-[72px] h-[72px] rounded-full bg-white items-center justify-center active:opacity-80"
|
||||||
|
style={{
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="w-16 h-16 rounded-full border-4 border-gray-300" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-white/70 text-xs mt-3">{t("scanner.capture")}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scanning overlay */}
|
||||||
|
{screenState === "scanning" && (
|
||||||
|
<View className="absolute inset-0 bg-black/70 items-center justify-center gap-4">
|
||||||
|
<ActivityIndicator size="large" color="#fff" />
|
||||||
|
<Text className="text-white text-base font-medium">{t("scanner.scanning")}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Booking overlay */}
|
||||||
|
{screenState === "booking" && (
|
||||||
|
<View className="absolute inset-0 bg-black/70 items-center justify-center gap-4">
|
||||||
|
<ActivityIndicator size="large" color="#fff" />
|
||||||
|
<Text className="text-white text-base font-medium">{t("common.loading")}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation sheet */}
|
||||||
|
<Modal
|
||||||
|
visible={screenState === "confirm"}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={handleRetry}
|
||||||
|
>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
{/* Sheet header */}
|
||||||
|
<View
|
||||||
|
className="flex-row items-center px-4 py-4 border-b border-gray-100"
|
||||||
|
style={{ paddingTop: insets.top > 0 ? 12 : 12 }}
|
||||||
|
>
|
||||||
|
<Pressable onPress={handleRetry} className="py-1 pr-4">
|
||||||
|
<Text className="text-base" style={{ color: ACCENT }}>
|
||||||
|
{t("scanner.retry")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text className="flex-1 text-center font-semibold text-base text-gray-900">
|
||||||
|
{t("scanner.detected")}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void handleBook()}
|
||||||
|
className="py-1 pl-4"
|
||||||
|
style={{ opacity: !amountStr || parseFloat(amountStr) <= 0 ? 0.4 : 1 }}
|
||||||
|
disabled={!amountStr || parseFloat(amountStr) <= 0}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-semibold" style={{ color: ACCENT }}>
|
||||||
|
{t("scanner.book")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
|
||||||
|
{/* Merchant */}
|
||||||
|
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
||||||
|
{t("scanner.merchant").toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={label}
|
||||||
|
onChangeText={setLabel}
|
||||||
|
placeholder={t("scanner.merchant")}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
||||||
|
{t("scanner.amount").toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<View className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 flex-row items-center mb-4">
|
||||||
|
<TextInput
|
||||||
|
value={amountStr}
|
||||||
|
onChangeText={setAmountStr}
|
||||||
|
placeholder="0.00"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
className="flex-1 text-base text-gray-900"
|
||||||
|
/>
|
||||||
|
<Text className="text-base text-gray-400 ml-2">€</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
||||||
|
{t("scanner.date").toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={dateDisplay}
|
||||||
|
onChangeText={(v) => {
|
||||||
|
setDateDisplay(v);
|
||||||
|
// Convert DD.MM.YYYY → YYYY-MM-DD for internal state
|
||||||
|
const parts = v.split(".");
|
||||||
|
if (parts.length === 3 && parts[2]?.length === 4) {
|
||||||
|
setDate(`${parts[2]}-${parts[1]?.padStart(2, "0")}-${parts[0]?.padStart(2, "0")}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="TT.MM.JJJJ"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<Text className="text-xs text-gray-400 font-medium mb-2 ml-1">
|
||||||
|
{t("scanner.category").toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
className="mb-4"
|
||||||
|
contentContainerStyle={{ gap: 8, paddingRight: 16 }}
|
||||||
|
>
|
||||||
|
{displayCategories.map((cat) => {
|
||||||
|
const isSelected = selectedCategoryId === cat.id;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={cat.id}
|
||||||
|
onPress={() =>
|
||||||
|
setSelectedCategoryId(isSelected ? undefined : cat.id)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? ACCENT : "#f3f4f6",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: isSelected ? ACCENT : "#e5e7eb",
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-full active:opacity-70"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: isSelected ? "#fff" : "#4b5563" }}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Scope */}
|
||||||
|
<Text className="text-xs text-gray-400 font-medium mb-2 ml-1">
|
||||||
|
{t("scanner.scope").toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row gap-3 mb-8">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setScope("household")}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: scope === "household" ? ACCENT : "#f3f4f6",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: scope === "household" ? ACCENT : "#e5e7eb",
|
||||||
|
}}
|
||||||
|
className="py-3 rounded-xl items-center active:opacity-70"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: scope === "household" ? "#fff" : "#4b5563" }}
|
||||||
|
>
|
||||||
|
{t("scanner.household")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setScope("private")}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: scope === "private" ? "#7C3AED" : "#f3f4f6",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: scope === "private" ? "#7C3AED" : "#e5e7eb",
|
||||||
|
}}
|
||||||
|
className="py-3 rounded-xl items-center active:opacity-70"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: scope === "private" ? "#fff" : "#4b5563" }}
|
||||||
|
>
|
||||||
|
{t("scanner.private")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
apps/native/app/(app)/settings/categories.tsx
Normal file
233
apps/native/app/(app)/settings/categories.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { useCategories, useDeleteCategory, useUpdateCategory, type Category } from "@/src/hooks/useCategories";
|
||||||
|
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
// ── Edit Modal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EditCategoryModal({
|
||||||
|
category,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
category: Category | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(category?.name ?? "");
|
||||||
|
const { mutate: update, isPending } = useUpdateCategory();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!category || !name.trim()) return;
|
||||||
|
update(
|
||||||
|
{ id: category.id, name: name.trim() },
|
||||||
|
{ onSuccess: onClose },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={!!category}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<ModalHeader
|
||||||
|
title={t('categories.editTitle')}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t('common.save')}
|
||||||
|
saveDisabled={!name.trim()}
|
||||||
|
saveLoading={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="px-4 mt-6">
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">{t('categories.nameLabel')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{category?.isDefault && (
|
||||||
|
<Text className="text-xs text-gray-400 mt-2">
|
||||||
|
{t('categories.defaultWarning')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category Row ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CategoryRow({
|
||||||
|
category,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
category: Category;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center justify-between py-3 border-b border-gray-100">
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<View
|
||||||
|
className="w-9 h-9 rounded-full items-center justify-center"
|
||||||
|
style={{ backgroundColor: category.color ?? "#6b7280" }}
|
||||||
|
>
|
||||||
|
<Ionicons name={category.icon} size={18} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text className="text-base text-gray-800">{category.name}</Text>
|
||||||
|
{category.isDefault && (
|
||||||
|
<View className="bg-gray-100 rounded px-1.5 py-0.5">
|
||||||
|
<Text className="text-xs text-gray-400">{t('categories.default')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<Pressable onPress={onEdit} className="p-1 active:opacity-50">
|
||||||
|
<Ionicons name="pencil-outline" size={18} color="#6b7280" />
|
||||||
|
</Pressable>
|
||||||
|
{!category.isDefault && (
|
||||||
|
<Pressable onPress={onDelete} className="p-1 active:opacity-50">
|
||||||
|
<Ionicons name="trash-outline" size={18} color="#dc2626" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Screen ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CategoriesScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: categories = [], isLoading } = useCategories();
|
||||||
|
const { mutate: deleteCategory } = useDeleteCategory();
|
||||||
|
|
||||||
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [addType, setAddType] = useState<"income" | "expense">("expense");
|
||||||
|
|
||||||
|
const expenseCategories = categories.filter((c) => c.type === "expense");
|
||||||
|
const incomeCategories = categories.filter((c) => c.type === "income");
|
||||||
|
|
||||||
|
function handleDelete(category: Category) {
|
||||||
|
Alert.alert(
|
||||||
|
t('categories.deleteTitle'),
|
||||||
|
t('categories.deleteMessage', { name: category.name }),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t('common.delete'),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () =>
|
||||||
|
deleteCategory(category.id, {
|
||||||
|
onError: (err) => {
|
||||||
|
Alert.alert(t('common.error'), err.message);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50" style={{ paddingTop: insets.top }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View className="flex-row items-center px-4 py-4 bg-white border-b border-gray-100">
|
||||||
|
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 active:opacity-50">
|
||||||
|
<Ionicons name="chevron-back" size={24} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-lg font-semibold text-gray-900">{t('settings.categories')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
|
{/* Expense Categories */}
|
||||||
|
<View className="mb-6 rounded-xl bg-white p-4">
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400">{t('categories.expenseSection')}</Text>
|
||||||
|
</View>
|
||||||
|
{expenseCategories.map((cat) => (
|
||||||
|
<CategoryRow
|
||||||
|
key={cat.id}
|
||||||
|
category={cat}
|
||||||
|
onEdit={() => setEditingCategory(cat)}
|
||||||
|
onDelete={() => handleDelete(cat)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { setAddType("expense"); setShowAddModal(true); }}
|
||||||
|
className="mt-3 flex-row items-center gap-2 py-2 active:opacity-50"
|
||||||
|
>
|
||||||
|
<Ionicons name="add-circle-outline" size={18} color="#2563EB" />
|
||||||
|
<Text className="text-sm font-medium text-blue-600">{t('categories.addExpenseCategory')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Income Categories */}
|
||||||
|
<View className="mb-6 rounded-xl bg-white p-4">
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400">{t('categories.incomeSection')}</Text>
|
||||||
|
</View>
|
||||||
|
{incomeCategories.map((cat) => (
|
||||||
|
<CategoryRow
|
||||||
|
key={cat.id}
|
||||||
|
category={cat}
|
||||||
|
onEdit={() => setEditingCategory(cat)}
|
||||||
|
onDelete={() => handleDelete(cat)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { setAddType("income"); setShowAddModal(true); }}
|
||||||
|
className="mt-3 flex-row items-center gap-2 py-2 active:opacity-50"
|
||||||
|
>
|
||||||
|
<Ionicons name="add-circle-outline" size={18} color="#2563EB" />
|
||||||
|
<Text className="text-sm font-medium text-blue-600">{t('categories.addIncomeCategory')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<EditCategoryModal
|
||||||
|
key={editingCategory?.id}
|
||||||
|
category={editingCategory}
|
||||||
|
onClose={() => setEditingCategory(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddCategoryModal
|
||||||
|
visible={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
defaultType={addType}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
apps/native/app/(app)/settings/fixed-costs.tsx
Normal file
317
apps/native/app/(app)/settings/fixed-costs.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
SectionList,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
useFixedCosts,
|
||||||
|
useCreateFixedCost,
|
||||||
|
useUpdateFixedCost,
|
||||||
|
useDeleteFixedCost,
|
||||||
|
type FixedCost,
|
||||||
|
type CreateFixedCostInput,
|
||||||
|
} from "@/src/hooks/useFixedCosts";
|
||||||
|
import { useCategories } from "@/src/hooks/useCategories";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Numpad } from "@/src/components/ui/Numpad";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||||
|
|
||||||
|
const SCOPE_LABEL_KEYS: Record<string, string> = {
|
||||||
|
household: "fixedCosts.household",
|
||||||
|
private: "fixedCosts.me",
|
||||||
|
child: "fixedCosts.children",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Add / Edit Modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ModalMode =
|
||||||
|
| { kind: "add"; scope: "household" | "private" | "child" }
|
||||||
|
| { kind: "edit"; item: FixedCost };
|
||||||
|
|
||||||
|
function FixedCostModal({
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
mode: ModalMode;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const isEdit = mode.kind === "edit";
|
||||||
|
const { data: categories = [] } = useCategories();
|
||||||
|
const { mutate: create, isPending: creating } = useCreateFixedCost();
|
||||||
|
const { mutate: update, isPending: updating } = useUpdateFixedCost();
|
||||||
|
const { t: tFn } = useTranslation();
|
||||||
|
|
||||||
|
const [label, setLabel] = useState(isEdit ? mode.item.label : "");
|
||||||
|
const [amountStr, setAmountStr] = useState(
|
||||||
|
isEdit ? String(mode.item.amount).replace(".", ",") : "0",
|
||||||
|
);
|
||||||
|
const [type, setType] = useState<"expense" | "income">(
|
||||||
|
isEdit ? mode.item.type : "expense",
|
||||||
|
);
|
||||||
|
const [categoryId, setCategoryId] = useState<string | null>(
|
||||||
|
isEdit ? (mode.item.categoryId ?? null) : null,
|
||||||
|
);
|
||||||
|
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||||
|
|
||||||
|
const filteredCategories = categories.filter((c) => c.type === type);
|
||||||
|
const selectedCategory = categories.find((c) => c.id === categoryId) ?? null;
|
||||||
|
const isPending = creating || updating;
|
||||||
|
|
||||||
|
function handleNumpad(key: string) {
|
||||||
|
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const amount = parseAmountStr(amountStr);
|
||||||
|
if (!label.trim() || !amount || amount <= 0) return;
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
update(
|
||||||
|
{ id: mode.item.id, input: { label: label.trim(), amount, categoryId } },
|
||||||
|
{ onSuccess: onClose },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const input: CreateFixedCostInput = {
|
||||||
|
scope: mode.scope,
|
||||||
|
label: label.trim(),
|
||||||
|
amount,
|
||||||
|
type,
|
||||||
|
categoryId: categoryId ?? undefined,
|
||||||
|
};
|
||||||
|
create(input, { onSuccess: onClose });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<ModalHeader
|
||||||
|
title={isEdit ? tFn('fixedCosts.editTitle') : tFn('fixedCosts.addTitle')}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel={tFn('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={tFn('common.save')}
|
||||||
|
saveDisabled={!canSave}
|
||||||
|
saveLoading={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingBottom: 24 }}>
|
||||||
|
{/* Amount */}
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-4 gap-3 mb-4">
|
||||||
|
{/* Type toggle (only for new) */}
|
||||||
|
{!isEdit && (
|
||||||
|
<View className="flex-row p-1 bg-gray-100 rounded-xl">
|
||||||
|
{(["expense", "income"] as const).map((t) => (
|
||||||
|
<Pressable
|
||||||
|
key={t}
|
||||||
|
onPress={() => { setType(t); setCategoryId(null); }}
|
||||||
|
className={`flex-1 py-2 rounded-lg items-center ${type === t ? "bg-white shadow-sm" : ""}`}
|
||||||
|
>
|
||||||
|
<Text className={`font-medium ${type === t ? (t === "expense" ? "text-red-600" : "text-green-600") : "text-gray-500"}`}>
|
||||||
|
{t === "expense" ? tFn('fixedCosts.expenseType') : tFn('fixedCosts.incomeType')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<View>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1">{tFn('fixedCosts.labelRequired')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={tFn('fixedCosts.labelPlaceholder')}
|
||||||
|
value={label}
|
||||||
|
onChangeText={setLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<View>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1">{tFn('fixedCosts.categoryOptional')}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowCategoryPicker((v) => !v)}
|
||||||
|
className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3"
|
||||||
|
>
|
||||||
|
<Text className="flex-1 text-base" style={{ color: selectedCategory ? "#111827" : "#9ca3af" }}>
|
||||||
|
{selectedCategory ? selectedCategory.name : tFn('common.select')}
|
||||||
|
</Text>
|
||||||
|
{selectedCategory ? (
|
||||||
|
<Pressable onPress={(e) => { e.stopPropagation(); setCategoryId(null); }} hitSlop={8}>
|
||||||
|
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
{showCategoryPicker && (
|
||||||
|
<View className="mt-1 bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||||
|
{filteredCategories.map((cat) => (
|
||||||
|
<Pressable
|
||||||
|
key={cat.id}
|
||||||
|
onPress={() => { setCategoryId(cat.id); setShowCategoryPicker(false); }}
|
||||||
|
className="flex-row items-center px-4 py-3 active:bg-gray-50"
|
||||||
|
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<View className="w-6 h-6 rounded-full mr-3 items-center justify-center" style={{ backgroundColor: cat.color ?? "#6b7280" }}>
|
||||||
|
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={12} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm text-gray-800">{cat.name}</Text>
|
||||||
|
{categoryId === cat.id && <Ionicons name="checkmark" size={16} color="#2563EB" style={{ marginLeft: "auto" }} />}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Numpad */}
|
||||||
|
<Numpad onKeyPress={handleNumpad} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FixedCostRow({
|
||||||
|
item,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
item: FixedCost;
|
||||||
|
onEdit: (item: FixedCost) => void;
|
||||||
|
onDelete: (item: FixedCost) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-50">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-gray-900">{item.label}</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{item.type === "expense" ? t('fixedCosts.expenseType') : t('fixedCosts.incomeType')} · {t('common.monthly')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm font-semibold text-gray-800 mr-3">{formatEur(item.amount, false)}</Text>
|
||||||
|
<Pressable onPress={() => onEdit(item)} hitSlop={8} className="mr-2 p-1">
|
||||||
|
<Ionicons name="pencil-outline" size={16} color="#6b7280" />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => onDelete(item)} hitSlop={8} className="p-1">
|
||||||
|
<Ionicons name="trash-outline" size={16} color="#d1d5db" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Screen ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function FixedCostsScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: allFixedCosts = [], isLoading } = useFixedCosts();
|
||||||
|
const { mutate: deleteCost } = useDeleteFixedCost();
|
||||||
|
const [modalMode, setModalMode] = useState<ModalMode | null>(null);
|
||||||
|
|
||||||
|
const active = allFixedCosts.filter((fc) => fc.isActive);
|
||||||
|
|
||||||
|
const sections = (["household", "private", "child"] as const)
|
||||||
|
.map((scope) => ({
|
||||||
|
scope,
|
||||||
|
title: t(SCOPE_LABEL_KEYS[scope] ?? scope),
|
||||||
|
data: active.filter((fc) => fc.scope === scope),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.data.length > 0 || true); // always show all scopes
|
||||||
|
|
||||||
|
function handleDelete(item: FixedCost) {
|
||||||
|
Alert.alert(
|
||||||
|
t('fixedCosts.pauseTitle'),
|
||||||
|
t('fixedCosts.pauseMessage', { label: item.label }),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: "cancel" },
|
||||||
|
{ text: t('fixedCosts.pause'), style: "destructive", onPress: () => deleteCost(item.id) },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
className="bg-white border-b border-gray-100"
|
||||||
|
style={{ paddingTop: insets.top }}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center px-4 py-3">
|
||||||
|
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 p-1">
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-base font-semibold text-gray-900 flex-1">{t('fixedCosts.title')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<SectionList
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderSectionHeader={({ section }) => (
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-3 bg-gray-50">
|
||||||
|
<Text className="text-xs font-semibold uppercase text-gray-500 tracking-wide">
|
||||||
|
{section.title}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setModalMode({ kind: "add", scope: section.scope })}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: "#dbeafe" }}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={14} color="#2563EB" />
|
||||||
|
<Text className="text-xs font-semibold text-blue-600">{t('common.new')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<FixedCostRow
|
||||||
|
item={item}
|
||||||
|
onEdit={(i) => setModalMode({ kind: "edit", item: i })}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderSectionFooter={({ section }) =>
|
||||||
|
section.data.length === 0 ? (
|
||||||
|
<View className="px-4 py-3 bg-white border-b border-gray-50">
|
||||||
|
<Text className="text-sm text-gray-400 italic">{t('fixedCosts.noItems')}</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalMode && (
|
||||||
|
<FixedCostModal mode={modalMode} onClose={() => setModalMode(null)} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
apps/native/app/(app)/settings/household.tsx
Normal file
269
apps/native/app/(app)/settings/household.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useHouseholdSettings, useUpdateHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||||
|
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
|
||||||
|
|
||||||
|
const ACCENT = "#2563EB";
|
||||||
|
const SHARE_PRESETS = [50, 60, 75, 100];
|
||||||
|
|
||||||
|
function SettingsRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
|
||||||
|
>
|
||||||
|
<Text className="text-base text-gray-900">{label}</Text>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Text className="text-base text-gray-500">{value}</Text>
|
||||||
|
<Ionicons name="pencil-outline" size={14} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditModal({
|
||||||
|
title,
|
||||||
|
initialValue,
|
||||||
|
keyboardType,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
initialValue: string;
|
||||||
|
keyboardType?: "default" | "decimal-pad";
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute", inset: 0, backgroundColor: "rgba(0,0,0,0.4)",
|
||||||
|
alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="bg-white rounded-2xl mx-6 p-5 w-full" style={{ maxWidth: 340 }}>
|
||||||
|
<Text className="text-base font-semibold text-gray-900 mb-3">{title}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
keyboardType={keyboardType ?? "default"}
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="words"
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
className="flex-1 py-3 rounded-xl items-center bg-gray-100 active:opacity-70"
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-gray-700">{t('common.cancel')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { onSave(value); onClose(); }}
|
||||||
|
className="flex-1 py-3 rounded-xl items-center active:opacity-70"
|
||||||
|
style={{ backgroundColor: ACCENT }}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-white">{t('common.save')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditingField = "ownerName" | "partnerName" | "monthlyBudget" | "userSharePercent" | null;
|
||||||
|
|
||||||
|
export default function HouseholdSettingsScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: settings, isLoading } = useHouseholdSettings();
|
||||||
|
const { data: membersData } = useHouseholdMembers();
|
||||||
|
const { mutate: update, isPending } = useUpdateHouseholdSettings();
|
||||||
|
const [editing, setEditing] = useState<EditingField>(null);
|
||||||
|
const members = membersData?.members ?? [];
|
||||||
|
|
||||||
|
function save(input: Parameters<typeof update>[0]) {
|
||||||
|
update(input, {
|
||||||
|
onError: () => Alert.alert(t('common.error'), t('settings.saveError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !settings) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
<View
|
||||||
|
className="bg-white border-b border-gray-100"
|
||||||
|
style={{ paddingTop: insets.top }}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center px-4 py-3">
|
||||||
|
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 p-1">
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-base font-semibold text-gray-900 flex-1">{t('settings.household.title')}</Text>
|
||||||
|
{isPending && <ActivityIndicator size="small" color={ACCENT} />}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 32 }}>
|
||||||
|
{/* Namen */}
|
||||||
|
<View className="bg-white rounded-2xl px-4 mb-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.namesSection')}</Text>
|
||||||
|
<SettingsRow
|
||||||
|
label={t('settings.household.yourName')}
|
||||||
|
value={settings.ownerName}
|
||||||
|
onPress={() => setEditing("ownerName")}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label={t('settings.household.partnerName')}
|
||||||
|
value={settings.partnerName}
|
||||||
|
onPress={() => setEditing("partnerName")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Wer zahlt die Ausgaben vor? */}
|
||||||
|
{members.length > 1 && (
|
||||||
|
<View className="bg-white rounded-2xl px-4 mb-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.payerSection')}</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mb-3">{t('settings.household.payerHint')}</Text>
|
||||||
|
<View className="flex-row gap-2 mb-3">
|
||||||
|
{members.map((m) => {
|
||||||
|
const isSelected = settings.payerUserId === m.userId;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={m.userId}
|
||||||
|
onPress={() => save({ payerUserId: m.userId })}
|
||||||
|
className="flex-1 py-2.5 rounded-xl items-center"
|
||||||
|
style={{ backgroundColor: isSelected ? ACCENT : "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold" style={{ color: isSelected ? "#fff" : "#374151" }}>
|
||||||
|
{m.name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kostenaufteilung */}
|
||||||
|
<View className="bg-white rounded-2xl px-4 mb-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-2">{t('settings.household.costSplitSection')}</Text>
|
||||||
|
|
||||||
|
<Text className="text-xs text-gray-400 mb-3">{t('settings.household.costSplitHint')}</Text>
|
||||||
|
<View className="flex-row gap-2 mb-3">
|
||||||
|
{SHARE_PRESETS.map((p) => (
|
||||||
|
<Pressable
|
||||||
|
key={p}
|
||||||
|
onPress={() => save({ userSharePercent: p })}
|
||||||
|
className="flex-1 py-2.5 rounded-xl items-center"
|
||||||
|
style={{ backgroundColor: settings.userSharePercent === p ? ACCENT : "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: settings.userSharePercent === p ? "#fff" : "#374151" }}
|
||||||
|
>
|
||||||
|
{p}%
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="rounded-xl px-3 py-2 mb-3"
|
||||||
|
style={{ backgroundColor: "#eff6ff" }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-blue-700">
|
||||||
|
{t('settings.household.sharePreview', { own: settings.userSharePercent, partner: settings.partnerName, rest: 100 - settings.userSharePercent })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
label={t('settings.household.monthlyBudget')}
|
||||||
|
value={`${settings.monthlyBudget.toFixed(0)} €`}
|
||||||
|
onPress={() => setEditing("monthlyBudget")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-row items-center justify-between py-3">
|
||||||
|
<Text className="text-base text-gray-900">{t('settings.household.splitChildren')}</Text>
|
||||||
|
<Switch
|
||||||
|
value={settings.splitChildCosts}
|
||||||
|
onValueChange={(v) => save({ splitChildCosts: v })}
|
||||||
|
trackColor={{ false: "#d1d5db", true: ACCENT }}
|
||||||
|
thumbColor="#fff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Währung */}
|
||||||
|
<View className="bg-white rounded-2xl px-4 mb-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.settingsSection')}</Text>
|
||||||
|
<SettingsRow
|
||||||
|
label={t('settings.household.currency')}
|
||||||
|
value={settings.currency}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert(t('settings.household.currency'), t('settings.household.currencyOnlyEur'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Inline Edit Modals */}
|
||||||
|
{editing === "ownerName" && (
|
||||||
|
<EditModal
|
||||||
|
title={t('settings.household.yourName')}
|
||||||
|
initialValue={settings.ownerName}
|
||||||
|
onSave={(v) => save({ ownerName: v.trim() || "Ich" })}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing === "partnerName" && (
|
||||||
|
<EditModal
|
||||||
|
title={t('settings.household.partnerName')}
|
||||||
|
initialValue={settings.partnerName}
|
||||||
|
onSave={(v) => save({ partnerName: v.trim() || "Partner" })}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing === "monthlyBudget" && (
|
||||||
|
<EditModal
|
||||||
|
title={t('settings.household.monthlyBudget')}
|
||||||
|
initialValue={String(settings.monthlyBudget)}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
onSave={(v) => save({ monthlyBudget: parseFloat(v.replace(",", ".")) || 400 })}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
442
apps/native/app/(app)/settings/index.tsx
Normal file
442
apps/native/app/(app)/settings/index.tsx
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import { useAuthStore } from "@/src/stores/auth.store";
|
||||||
|
import { signOut } from "@/src/lib/auth-client";
|
||||||
|
import {
|
||||||
|
useHouseholdMembers,
|
||||||
|
useRevokeInvitation,
|
||||||
|
type PendingInvitation,
|
||||||
|
type HouseholdMember,
|
||||||
|
} from "@/src/hooks/useHouseholdMembers";
|
||||||
|
import { useHouseholdSettings, useUpdateHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { useGenerateInviteCode } from "@/src/hooks/useInvite";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
ToastAndroid,
|
||||||
|
Platform,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Share,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n from "@/src/i18n";
|
||||||
|
import * as Localization from "expo-localization";
|
||||||
|
|
||||||
|
function showToast(message: string) {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
ToastAndroid.show(message, ToastAndroid.SHORT);
|
||||||
|
} else {
|
||||||
|
Alert.alert("", message, [{ text: "OK" }], { cancelable: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invite Code Modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function InviteCodeModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate: generate, data, isPending, reset } = useGenerateInviteCode();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
reset();
|
||||||
|
setCopied(false);
|
||||||
|
generate();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const code = data?.code ?? "";
|
||||||
|
|
||||||
|
async function handleShare() {
|
||||||
|
if (!code) return;
|
||||||
|
await Share.share({ message: t('invite.shareText', { code }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
if (!code) return;
|
||||||
|
await Share.share({ message: code });
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
reset();
|
||||||
|
setCopied(false);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<ModalHeader
|
||||||
|
title={t('invite.title')}
|
||||||
|
onClose={handleClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
|
{isPending ? (
|
||||||
|
<View className="items-center gap-3">
|
||||||
|
<ActivityIndicator size="large" color="#2563EB" />
|
||||||
|
<Text className="text-sm text-gray-400">{t('invite.generating')}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Code display */}
|
||||||
|
<View className="items-center mb-2 rounded-2xl bg-gray-50 px-8 py-6">
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: "700",
|
||||||
|
letterSpacing: 10,
|
||||||
|
color: "#111827",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{code || "------"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-sm text-gray-400 mb-10">{t('invite.validFor')}</Text>
|
||||||
|
|
||||||
|
{/* Copy button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCopy}
|
||||||
|
className="w-full mb-3 flex-row items-center justify-center gap-2 rounded-xl bg-blue-600 py-4 active:opacity-80"
|
||||||
|
>
|
||||||
|
<Ionicons name="copy-outline" size={18} color="white" />
|
||||||
|
<Text className="text-base font-semibold text-white">
|
||||||
|
{copied ? t('invite.copied') : t('invite.copyCode')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Share button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleShare}
|
||||||
|
className="w-full mb-8 flex-row items-center justify-center gap-2 rounded-xl border border-blue-200 py-4 active:opacity-80"
|
||||||
|
>
|
||||||
|
<Ionicons name="share-outline" size={18} color="#2563EB" />
|
||||||
|
<Text className="text-base font-semibold text-blue-600">{t('invite.share')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Regenerate link */}
|
||||||
|
<Pressable onPress={() => { setCopied(false); generate(); }} className="active:opacity-60">
|
||||||
|
<Text className="text-sm text-gray-400 underline">{t('invite.newCode')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Members Section ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MembersSection() {
|
||||||
|
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||||
|
const { data, isLoading } = useHouseholdMembers();
|
||||||
|
const { mutate: revoke } = useRevokeInvitation();
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function handleRevoke(inv: PendingInvitation) {
|
||||||
|
Alert.alert(
|
||||||
|
t('settings.revokeTitle'),
|
||||||
|
t('settings.revokeMessage', { email: inv.email }),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t('settings.revoke'),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => revoke(inv.id, { onSuccess: () => showToast(t('settings.revokeSuccess')) }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className="mb-6 rounded-xl bg-white p-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.members')}</Text>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<ActivityIndicator size="small" color="#2563EB" style={{ marginVertical: 8 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active members */}
|
||||||
|
{data?.members.map((m: HouseholdMember) => (
|
||||||
|
<View
|
||||||
|
key={m.userId}
|
||||||
|
className="flex-row items-center justify-between py-3 border-b border-gray-100"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<View
|
||||||
|
className="w-8 h-8 rounded-full items-center justify-center"
|
||||||
|
style={{ backgroundColor: m.userId === currentUserId ? "#2563EB" : "#e5e7eb" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-bold"
|
||||||
|
style={{ color: m.userId === currentUserId ? "#fff" : "#6b7280" }}
|
||||||
|
>
|
||||||
|
{m.name.charAt(0).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text className="text-base text-gray-900">
|
||||||
|
{m.name}{m.userId === currentUserId ? ` ${t('settings.youSuffix')}` : ""}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-400">{m.email}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs text-gray-400 capitalize">{m.role}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Pending invitations */}
|
||||||
|
{(data?.pendingInvitations ?? []).length > 0 && (
|
||||||
|
<View className="mt-2">
|
||||||
|
<Text className="text-xs text-gray-400 mb-1">{t('settings.pending')}</Text>
|
||||||
|
{data!.pendingInvitations.map((inv: PendingInvitation) => (
|
||||||
|
<View
|
||||||
|
key={inv.id}
|
||||||
|
className="flex-row items-center justify-between py-3 border-b border-gray-100"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<View className="w-8 h-8 rounded-full items-center justify-center bg-gray-100">
|
||||||
|
<Ionicons name="mail-outline" size={16} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
<Text className="text-base text-gray-500">{inv.email}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => handleRevoke(inv)} className="p-1 active:opacity-50">
|
||||||
|
<Ionicons name="close-circle-outline" size={20} color="#dc2626" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invite button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowInviteModal(true)}
|
||||||
|
className="mt-3 flex-row items-center justify-center gap-1.5 rounded-lg border border-blue-200 py-3 active:opacity-70"
|
||||||
|
>
|
||||||
|
<Ionicons name="person-add-outline" size={16} color="#2563EB" />
|
||||||
|
<Text className="text-sm font-medium text-blue-600">{t('settings.invitePerson')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<InviteCodeModal visible={showInviteModal} onClose={() => setShowInviteModal(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Screen ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const { user, households, activeHouseholdId, setActiveHousehold } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: hhSettings } = useHouseholdSettings();
|
||||||
|
const { mutate: updateSettings } = useUpdateHouseholdSettings();
|
||||||
|
|
||||||
|
// Apply saved language preference when settings load
|
||||||
|
useEffect(() => {
|
||||||
|
if (hhSettings?.language && hhSettings.language !== "auto") {
|
||||||
|
void i18n.changeLanguage(hhSettings.language);
|
||||||
|
}
|
||||||
|
}, [hhSettings?.language]);
|
||||||
|
|
||||||
|
function handleLanguageChange() {
|
||||||
|
const deviceLanguage = Localization.getLocales()[0]?.languageCode ?? "de";
|
||||||
|
Alert.alert(t('settings.language'), undefined, [
|
||||||
|
{
|
||||||
|
text: t('settings.languageAuto'),
|
||||||
|
onPress: () => {
|
||||||
|
void i18n.changeLanguage(deviceLanguage);
|
||||||
|
updateSettings({ language: "auto" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t('settings.languageDe'),
|
||||||
|
onPress: () => {
|
||||||
|
void i18n.changeLanguage("de");
|
||||||
|
updateSettings({ language: "de" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t('settings.languageEn'),
|
||||||
|
onPress: () => {
|
||||||
|
void i18n.changeLanguage("en");
|
||||||
|
updateSettings({ language: "en" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ text: t('common.cancel'), style: "cancel" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSwitch(household: { id: string; name: string }) {
|
||||||
|
if (household.id === activeHouseholdId) return;
|
||||||
|
setActiveHousehold(household.id);
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
showToast(t('settings.switchedTo', { name: household.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
await signOut();
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
router.replace("/(auth)/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 bg-gray-50"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingTop: insets.top + 8 }}
|
||||||
|
>
|
||||||
|
{/* Back + Title */}
|
||||||
|
<View className="flex-row items-center mb-5">
|
||||||
|
<Pressable onPress={() => router.push("/(app)/mehr")} className="mr-3 p-1">
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-xl font-bold text-gray-900">{t('settings.title')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<View className="mb-6 rounded-xl bg-white p-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 mb-2">{t('settings.account')}</Text>
|
||||||
|
<Text className="text-base font-semibold text-gray-900">{user?.name}</Text>
|
||||||
|
<Text className="text-sm text-gray-500">{user?.email}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Household Switcher */}
|
||||||
|
<View className="mb-6 rounded-xl bg-white p-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.households')}</Text>
|
||||||
|
{households.map((h) => (
|
||||||
|
<Pressable
|
||||||
|
key={h.id}
|
||||||
|
onPress={() => handleSwitch(h)}
|
||||||
|
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70 last:border-b-0"
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text className="text-base text-gray-900">{h.name}</Text>
|
||||||
|
<Text className="text-xs text-gray-400 capitalize">{h.role}</Text>
|
||||||
|
</View>
|
||||||
|
{activeHouseholdId === h.id && (
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color="#2563EB" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push("/(auth)/onboarding")}
|
||||||
|
className="mt-3 flex-row items-center justify-center gap-1.5 rounded-lg border border-blue-200 py-3 active:opacity-70"
|
||||||
|
>
|
||||||
|
<Ionicons name="add-circle-outline" size={16} color="#2563EB" />
|
||||||
|
<Text className="text-sm font-medium text-blue-600">{t('onboarding.createHousehold')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Members + Invite */}
|
||||||
|
<MembersSection />
|
||||||
|
|
||||||
|
{/* Household Settings */}
|
||||||
|
<View className="mb-6 rounded-xl bg-white p-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('tabs.household')}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push("/(app)/settings/household")}
|
||||||
|
className="flex-row items-center justify-between py-3 active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Ionicons name="people-outline" size={20} color="#6b7280" />
|
||||||
|
<Text className="text-base text-gray-900">{t('settings.householdPartner')}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* App Settings */}
|
||||||
|
<View className="mb-6 rounded-xl bg-white p-4">
|
||||||
|
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.appSection')}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push("/(app)/settings/categories")}
|
||||||
|
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Ionicons name="pricetags-outline" size={20} color="#6b7280" />
|
||||||
|
<Text className="text-base text-gray-900">{t('settings.categories')}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push("/(app)/settings/fixed-costs")}
|
||||||
|
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Ionicons name="repeat-outline" size={20} color="#6b7280" />
|
||||||
|
<Text className="text-base text-gray-900">{t('settings.fixedCosts')}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push("/(app)/settings/transfer-line-items")}
|
||||||
|
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Ionicons name="swap-horizontal-outline" size={20} color="#6b7280" />
|
||||||
|
<Text className="text-base text-gray-900">{t('settings.transferItems')}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleLanguageChange}
|
||||||
|
className="flex-row items-center justify-between py-3 active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Ionicons name="language-outline" size={20} color="#6b7280" />
|
||||||
|
<Text className="text-base text-gray-900">{t('settings.language')}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Text className="text-sm text-gray-400">
|
||||||
|
{(() => {
|
||||||
|
switch (hhSettings?.language) {
|
||||||
|
case "de": return t('settings.languageDe');
|
||||||
|
case "en": return t('settings.languageEn');
|
||||||
|
default: return t('settings.languageAuto');
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sign Out */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSignOut}
|
||||||
|
className="rounded-xl bg-red-50 p-4 flex-row items-center justify-center gap-2 active:opacity-70"
|
||||||
|
>
|
||||||
|
<Ionicons name="log-out-outline" size={18} color="#dc2626" />
|
||||||
|
<Text className="text-base font-semibold text-red-600">{t('settings.logout')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
apps/native/app/(app)/settings/transfer-line-items.tsx
Normal file
165
apps/native/app/(app)/settings/transfer-line-items.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
FlatList,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
useTransferLineItems,
|
||||||
|
useCreateTransferLineItem,
|
||||||
|
useDeleteTransferLineItem,
|
||||||
|
type TransferLineItem,
|
||||||
|
} from "@/src/hooks/useFixedCosts";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Numpad } from "@/src/components/ui/Numpad";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||||
|
|
||||||
|
function AddModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [amountStr, setAmountStr] = useState("0");
|
||||||
|
const { mutate: create, isPending } = useCreateTransferLineItem();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function handleNumpad(key: string) {
|
||||||
|
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const amount = parseAmountStr(amountStr);
|
||||||
|
if (!label.trim() || !amount || amount <= 0) return;
|
||||||
|
create({ label: label.trim(), amount }, { onSuccess: onClose });
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<ModalHeader
|
||||||
|
title={t('transferItems.addTitle')}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t('common.save')}
|
||||||
|
saveDisabled={!canSave}
|
||||||
|
saveLoading={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||||
|
<Text className="text-sm text-gray-400 mt-1">{t('transferItems.monthlyFixedAmount')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-4 mb-4">
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1">{t('transferItems.labelRequired')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('transferItems.labelPlaceholder')}
|
||||||
|
value={label}
|
||||||
|
onChangeText={setLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Numpad onKeyPress={handleNumpad} />
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransferLineItemsScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: items = [], isLoading } = useTransferLineItems();
|
||||||
|
const { mutate: deleteItem } = useDeleteTransferLineItem();
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
const total = items.reduce((sum, i) => sum + i.amount, 0);
|
||||||
|
|
||||||
|
function handleDelete(item: TransferLineItem) {
|
||||||
|
Alert.alert(
|
||||||
|
t('transferItems.removeTitle'),
|
||||||
|
t('transferItems.removeMessage', { label: item.label }),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: "cancel" },
|
||||||
|
{ text: t('transferItems.remove'), style: "destructive", onPress: () => deleteItem(item.id) },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
<View className="bg-white border-b border-gray-100" style={{ paddingTop: insets.top }}>
|
||||||
|
<View className="flex-row items-center px-4 py-3">
|
||||||
|
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 p-1">
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-base font-semibold text-gray-900 flex-1">{t('transferItems.title')}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAdd(true)}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: "#dbeafe" }}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={14} color="#2563EB" />
|
||||||
|
<Text className="text-xs font-semibold text-blue-600">{t('transferItems.new')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-xs text-gray-400 px-4 py-3">
|
||||||
|
{t('transferItems.hint')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-50">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-gray-900">{item.label}</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mt-0.5">{t('common.monthly')}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm font-semibold text-gray-800 mr-3">{formatEur(item.amount, false)}</Text>
|
||||||
|
<Pressable onPress={() => handleDelete(item)} hitSlop={8} className="p-1">
|
||||||
|
<Ionicons name="trash-outline" size={16} color="#d1d5db" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="px-4 py-8 items-center">
|
||||||
|
<Text className="text-sm text-gray-400 text-center">
|
||||||
|
{t('transferItems.empty')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
items.length > 0 ? (
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-3 bg-white mt-3">
|
||||||
|
<Text className="text-sm font-semibold text-gray-700">{t('transferItems.totalMonthly')}</Text>
|
||||||
|
<Text className="text-sm font-bold text-blue-600">{formatEur(total, false)}</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd && <AddModal onClose={() => setShowAdd(false)} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
apps/native/app/(app)/shopping-list/index.tsx
Normal file
208
apps/native/app/(app)/shopping-list/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useShoppingList, type ShoppingItem } from "@/src/hooks/useShoppingList";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const ACCENT = "#16a34a";
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: "connecting" | "connected" | "offline" }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (status === "connected") {
|
||||||
|
return <View className="w-2 h-2 rounded-full bg-green-500" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<View className="w-2 h-2 rounded-full bg-gray-400" />
|
||||||
|
{status === "offline" && (
|
||||||
|
<Text className="text-xs text-gray-400">{t("shopping.offline")}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShoppingItemRow({
|
||||||
|
item,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
item: ShoppingItem;
|
||||||
|
onToggle: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const isChecked = item.checkedBy !== null;
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center px-4 py-3 bg-white">
|
||||||
|
<TouchableOpacity onPress={onToggle} className="mr-3 active:opacity-60">
|
||||||
|
<Ionicons
|
||||||
|
name={isChecked ? "checkbox" : "square-outline"}
|
||||||
|
size={24}
|
||||||
|
color={isChecked ? "#9ca3af" : ACCENT}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text
|
||||||
|
className="flex-1 text-base"
|
||||||
|
style={{
|
||||||
|
color: isChecked ? "#9ca3af" : "#111827",
|
||||||
|
textDecorationLine: isChecked ? "line-through" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{item.quantity ? (
|
||||||
|
<Text style={{ color: "#9ca3af", fontSize: 13 }}> {item.quantity}</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={onDelete} className="p-1 active:opacity-60">
|
||||||
|
<Ionicons name="trash-outline" size={18} color="#d1d5db" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShoppingListScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { items, status, addItem, toggleItem, deleteItem, deleteChecked } = useShoppingList();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [quantity, setQuantity] = useState("");
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const unchecked = items.filter((i) => i.checkedBy === null);
|
||||||
|
const checked = items.filter((i) => i.checkedBy !== null);
|
||||||
|
const sorted = [...unchecked, ...checked];
|
||||||
|
const hasChecked = checked.length > 0;
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
addItem(trimmed, quantity.trim() || undefined);
|
||||||
|
setText("");
|
||||||
|
setQuantity("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-gray-50"
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
keyboardVerticalOffset={0}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
paddingTop: insets.top,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
className="px-4 pb-3 flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
<Text className="text-xl font-bold text-gray-900">{t("shopping.title")}</Text>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
{hasChecked && (
|
||||||
|
<Pressable
|
||||||
|
onPress={deleteChecked}
|
||||||
|
className="rounded-full px-3 py-1 active:opacity-70"
|
||||||
|
style={{ backgroundColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-medium text-gray-600">
|
||||||
|
{t("shopping.deleteChecked")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<StatusDot status={status} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<FlatList
|
||||||
|
data={sorted}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item, index }) => {
|
||||||
|
const isFirstChecked =
|
||||||
|
item.checkedBy !== null &&
|
||||||
|
(index === 0 || sorted[index - 1]?.checkedBy === null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isFirstChecked && unchecked.length > 0 && (
|
||||||
|
<View className="h-px bg-gray-200 mx-4 my-1" />
|
||||||
|
)}
|
||||||
|
<ShoppingItemRow
|
||||||
|
item={item}
|
||||||
|
onToggle={() => toggleItem(item)}
|
||||||
|
onDelete={() => deleteItem(item.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-px bg-gray-100 ml-14" />}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="flex-1 items-center justify-center py-24">
|
||||||
|
<Ionicons
|
||||||
|
name="cart-outline"
|
||||||
|
size={48}
|
||||||
|
color="#d1d5db"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<Text className="text-base font-medium text-gray-700 mb-1">
|
||||||
|
{t("shopping.empty")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-400 text-center px-8">
|
||||||
|
{t("shopping.emptyHint")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={sorted.length === 0 ? { flex: 1 } : { paddingBottom: 8 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Input bar */}
|
||||||
|
<View
|
||||||
|
style={{ paddingBottom: insets.bottom || 16 }}
|
||||||
|
className="px-4 pt-3 bg-white border-t border-gray-100"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3 mb-2">
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
placeholder={t("shopping.placeholder")}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleAdd}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
className="flex-1 text-base text-gray-900 py-2"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleAdd}
|
||||||
|
disabled={!text.trim()}
|
||||||
|
style={{ backgroundColor: text.trim() ? ACCENT : "#e5e7eb" }}
|
||||||
|
className="w-9 h-9 rounded-full items-center justify-center active:opacity-70"
|
||||||
|
>
|
||||||
|
<Ionicons name="arrow-up" size={18} color={text.trim() ? "#fff" : "#9ca3af"} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
value={quantity}
|
||||||
|
onChangeText={setQuantity}
|
||||||
|
placeholder={t("shopping.quantityPlaceholder")}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleAdd}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
className="text-sm text-gray-600 py-1"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
apps/native/app/(app)/transactions/index.tsx
Normal file
109
apps/native/app/(app)/transactions/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { QuickAddModal } from "@/src/components/features/transactions/QuickAddModal";
|
||||||
|
import { SummaryHeader } from "@/src/components/features/transactions/SummaryHeader";
|
||||||
|
import { TransactionItem } from "@/src/components/features/transactions/TransactionItem";
|
||||||
|
import { EditTransactionModal } from "@/src/components/features/transactions/EditTransactionModal";
|
||||||
|
import { useTransactions, useTransactionSummary, useDeleteTransaction, type TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type FilterType = "all" | "income" | "expense";
|
||||||
|
|
||||||
|
export default function TransactionsScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [filter, setFilter] = useState<FilterType>("all");
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editTransaction, setEditTransaction] = useState<TransactionWithCategory | null>(null);
|
||||||
|
const { mutate: deleteTransaction } = useDeleteTransaction();
|
||||||
|
|
||||||
|
const transactionFilter = filter === "all" ? undefined : { type: filter as "income" | "expense" };
|
||||||
|
const { data: transactions = [], isLoading, refetch, isRefetching } = useTransactions(transactionFilter);
|
||||||
|
const { data: summary, isLoading: summaryLoading } = useTransactionSummary();
|
||||||
|
|
||||||
|
function renderEmpty() {
|
||||||
|
if (isLoading) return null;
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center py-20">
|
||||||
|
<Ionicons name="wallet-outline" size={48} color="#d1d5db" style={{ marginBottom: 12 }} />
|
||||||
|
<Text className="text-base font-medium text-gray-700 mb-1">Noch keine Buchungen</Text>
|
||||||
|
<Text className="text-sm text-gray-400">Tippe auf + um deine erste Buchung einzutragen</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<View className="bg-blue-600" style={{ paddingTop: insets.top }}>
|
||||||
|
<SummaryHeader summary={summary} isLoading={summaryLoading} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<View className="flex-row px-4 py-3 gap-2 border-b border-gray-100">
|
||||||
|
{(["all", "expense", "income"] as const).map((f) => (
|
||||||
|
<Pressable
|
||||||
|
key={f}
|
||||||
|
onPress={() => setFilter(f)}
|
||||||
|
className={`px-4 py-1.5 rounded-full ${filter === f ? "bg-blue-600" : "bg-gray-100"}`}
|
||||||
|
>
|
||||||
|
<Text className={`text-sm font-medium ${filter === f ? "text-white" : "text-gray-600"}`}>
|
||||||
|
{f === "all" ? "Alle" : f === "expense" ? "Ausgaben" : "Einnahmen"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Transaction List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={transactions}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TransactionItem
|
||||||
|
transaction={item}
|
||||||
|
onPress={setEditTransaction}
|
||||||
|
onDelete={(t) => deleteTransaction(t.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isRefetching} onRefresh={() => void refetch()} />
|
||||||
|
}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-px bg-gray-50 ml-16" />}
|
||||||
|
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAddModal(true)}
|
||||||
|
className="absolute bottom-6 right-6 w-14 h-14 bg-blue-600 rounded-full items-center justify-center shadow-lg active:opacity-80"
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={28} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<QuickAddModal
|
||||||
|
visible={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onRequestAddCategory={() => {}}
|
||||||
|
/>
|
||||||
|
{editTransaction && (
|
||||||
|
<EditTransactionModal
|
||||||
|
transaction={editTransaction}
|
||||||
|
onClose={() => setEditTransaction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
860
apps/native/app/(app)/urlaub/[id].tsx
Normal file
860
apps/native/app/(app)/urlaub/[id].tsx
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Numpad } from "@/src/components/ui/Numpad";
|
||||||
|
import { TAB_COLORS } from "@/src/constants/colors";
|
||||||
|
import {
|
||||||
|
useTrip,
|
||||||
|
useTripExpenses,
|
||||||
|
useCreateTripExpense,
|
||||||
|
useDeleteTripExpense,
|
||||||
|
useCompleteTrip,
|
||||||
|
type TripExpense,
|
||||||
|
type TripSettlement,
|
||||||
|
type CreateTripExpenseInput,
|
||||||
|
} from "@/src/hooks/useTrips";
|
||||||
|
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
|
||||||
|
import { useAuthStore } from "@/src/stores/auth.store";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
import { todayIso } from "@/src/utils/date";
|
||||||
|
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const ACCENT = TAB_COLORS.shopping; // green #16A34A
|
||||||
|
|
||||||
|
// ── Category config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ExpenseCategory = TripExpense["category"];
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<ExpenseCategory, React.ComponentProps<typeof Ionicons>["name"]> = {
|
||||||
|
unterkunft: "bed-outline",
|
||||||
|
essen: "restaurant-outline",
|
||||||
|
transport: "car-outline",
|
||||||
|
aktivitaeten: "ticket-outline",
|
||||||
|
sonstiges: "cube-outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: ExpenseCategory[] = [
|
||||||
|
"unterkunft",
|
||||||
|
"essen",
|
||||||
|
"transport",
|
||||||
|
"aktivitaeten",
|
||||||
|
"sonstiges",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDateRange(startDate: string, endDate: string): string {
|
||||||
|
const fmt = (d: string) => {
|
||||||
|
const parts = d.split("-");
|
||||||
|
return `${parts[2]}.${parts[1]}.${parts[0]?.slice(2)}`;
|
||||||
|
};
|
||||||
|
return `${fmt(startDate)} – ${fmt(endDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBudgetColor(remaining: number, budget: number): string {
|
||||||
|
if (remaining <= 0) return "#dc2626";
|
||||||
|
if (remaining < budget * 0.1) return "#ea580c";
|
||||||
|
return ACCENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress Bar ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProgressBar({
|
||||||
|
spent,
|
||||||
|
budget,
|
||||||
|
color,
|
||||||
|
height = 6,
|
||||||
|
}: {
|
||||||
|
spent: number;
|
||||||
|
budget: number;
|
||||||
|
color: string;
|
||||||
|
height?: number;
|
||||||
|
}) {
|
||||||
|
const ratio = budget > 0 ? Math.min(spent / budget, 1) : 0;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{ height, borderRadius: height / 2, backgroundColor: "#f3f4f6", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${ratio * 100}%`,
|
||||||
|
height,
|
||||||
|
backgroundColor: color,
|
||||||
|
borderRadius: height / 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget Summary Card ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BudgetSummaryCard({
|
||||||
|
budget,
|
||||||
|
totalSpent,
|
||||||
|
remaining,
|
||||||
|
}: {
|
||||||
|
budget: number;
|
||||||
|
totalSpent: number;
|
||||||
|
remaining: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const color = getBudgetColor(remaining, budget);
|
||||||
|
const isOver = remaining <= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mx-4 mt-4 bg-white rounded-2xl p-4"
|
||||||
|
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<View className="flex-row justify-between mb-3">
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-xs text-gray-400 mb-1">{t("trips.budget")}</Text>
|
||||||
|
<Text className="text-lg font-bold text-gray-900">{formatEur(budget)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="w-px bg-gray-100" />
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-xs text-gray-400 mb-1">{t("trips.spent")}</Text>
|
||||||
|
<Text className="text-lg font-bold text-gray-700">{formatEur(totalSpent)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="w-px bg-gray-100" />
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-xs text-gray-400 mb-1">{t("trips.remaining")}</Text>
|
||||||
|
<Text className="text-lg font-bold" style={{ color }}>
|
||||||
|
{isOver ? `−${formatEur(Math.abs(remaining))}` : formatEur(remaining)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ProgressBar spent={totalSpent} budget={budget} color={color} height={8} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category Section ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CategorySection({
|
||||||
|
byCategory,
|
||||||
|
budget,
|
||||||
|
}: {
|
||||||
|
byCategory: Record<string, number>;
|
||||||
|
budget: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const categories = CATEGORY_ORDER.filter((cat) => (byCategory[cat] ?? 0) > 0);
|
||||||
|
|
||||||
|
if (categories.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mx-4 mt-4 bg-white rounded-2xl overflow-hidden" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||||
|
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-3 pb-2">
|
||||||
|
{t("trips.budget")} nach Kategorie
|
||||||
|
</Text>
|
||||||
|
{categories.map((cat, index) => {
|
||||||
|
const amount = byCategory[cat] ?? 0;
|
||||||
|
const icon = CATEGORY_ICONS[cat];
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={cat}
|
||||||
|
className="px-4 py-3"
|
||||||
|
style={index < categories.length - 1 ? { borderBottomWidth: 1, borderBottomColor: "#f9fafb" } : undefined}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center mb-1.5">
|
||||||
|
<View
|
||||||
|
className="w-7 h-7 rounded-lg items-center justify-center mr-3"
|
||||||
|
style={{ backgroundColor: `${ACCENT}18` }}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={14} color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm text-gray-700 flex-1">
|
||||||
|
{t(`trips.categories.${cat}`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm font-semibold text-gray-800">{formatEur(amount)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="ml-10">
|
||||||
|
<ProgressBar spent={amount} budget={budget} color={ACCENT} height={3} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expense Row ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ExpenseRow({
|
||||||
|
expense,
|
||||||
|
memberName,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
expense: TripExpense;
|
||||||
|
memberName: string;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const icon = CATEGORY_ICONS[expense.category];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center px-4 py-3 bg-white">
|
||||||
|
<View
|
||||||
|
className="w-9 h-9 rounded-xl items-center justify-center mr-3"
|
||||||
|
style={{ backgroundColor: `${ACCENT}18` }}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={18} color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 mr-2">
|
||||||
|
<Text className="text-sm font-medium text-gray-800">{expense.label}</Text>
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{t("trips.paidBy", { name: memberName })} · {expense.date}
|
||||||
|
</Text>
|
||||||
|
{expense.note && (
|
||||||
|
<Text className="text-xs text-gray-400 italic">{expense.note}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm font-semibold text-gray-700 mr-3">{formatEur(expense.amount)}</Text>
|
||||||
|
<TouchableOpacity onPress={onDelete} className="p-1 active:opacity-60">
|
||||||
|
<Ionicons name="trash-outline" size={18} color="#d1d5db" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Closed Banner ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ClosedBanner({
|
||||||
|
settlementAmount,
|
||||||
|
settlementFromUserId,
|
||||||
|
settlementToUserId,
|
||||||
|
getMemberName,
|
||||||
|
}: {
|
||||||
|
settlementAmount: number | null;
|
||||||
|
settlementFromUserId: string | null;
|
||||||
|
settlementToUserId: string | null;
|
||||||
|
getMemberName: (userId: string) => string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasSettlement =
|
||||||
|
settlementAmount !== null &&
|
||||||
|
settlementAmount > 0.01 &&
|
||||||
|
settlementFromUserId !== null &&
|
||||||
|
settlementToUserId !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mx-4 mt-4 rounded-2xl px-4 py-3"
|
||||||
|
style={{ backgroundColor: "#dcfce7" }}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-2 mb-1">
|
||||||
|
<Ionicons name="lock-closed" size={14} color="#15803d" />
|
||||||
|
<Text className="text-sm font-semibold" style={{ color: "#15803d" }}>
|
||||||
|
{t("trips.settlement.closedBanner")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{hasSettlement && settlementFromUserId !== null && settlementToUserId !== null ? (
|
||||||
|
<Text className="text-xs" style={{ color: "#166534" }}>
|
||||||
|
{t("trips.settlement.settledInfo", {
|
||||||
|
from: getMemberName(settlementFromUserId),
|
||||||
|
to: getMemberName(settlementToUserId),
|
||||||
|
amount: formatEur(settlementAmount ?? 0),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="text-xs" style={{ color: "#166534" }}>
|
||||||
|
{t("trips.settlement.balanced")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settlement Modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SettlementModal({
|
||||||
|
settlement,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
settlement: TripSettlement;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const hasSettlement = settlement.settlement !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||||
|
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom + 16 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
className="flex-row items-center justify-between px-4 py-4"
|
||||||
|
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold text-gray-900">
|
||||||
|
{t("trips.settlement.title")}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={onClose} className="p-1 active:opacity-70">
|
||||||
|
<Ionicons name="close" size={22} color="#6b7280" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, gap: 4 }}>
|
||||||
|
{/* Total row */}
|
||||||
|
<View className="flex-row justify-between py-3">
|
||||||
|
<Text className="text-sm text-gray-600">{t("trips.settlement.total")}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-gray-900">
|
||||||
|
{formatEur(settlement.total)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fair share row */}
|
||||||
|
<View className="flex-row justify-between py-3">
|
||||||
|
<Text className="text-sm text-gray-600">{t("trips.settlement.fairShare")}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-gray-900">
|
||||||
|
{formatEur(settlement.fairShare)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={{ height: 1, backgroundColor: "#f3f4f6", marginVertical: 4 }} />
|
||||||
|
|
||||||
|
{/* Per-person rows */}
|
||||||
|
{settlement.balances.map((balance) => (
|
||||||
|
<View key={balance.userId} className="py-3">
|
||||||
|
<View className="flex-row justify-between mb-1.5">
|
||||||
|
<Text className="text-sm font-medium text-gray-700">{balance.name}</Text>
|
||||||
|
<Text className="text-sm text-gray-500">
|
||||||
|
{formatEur(balance.paid)} {t("trips.settlement.paid")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
spent={balance.paid}
|
||||||
|
budget={settlement.total > 0 ? settlement.total : 1}
|
||||||
|
color={balance.balance >= 0 ? ACCENT : "#f97316"}
|
||||||
|
height={4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={{ height: 1, backgroundColor: "#f3f4f6", marginVertical: 4 }} />
|
||||||
|
|
||||||
|
{/* Settlement result box */}
|
||||||
|
<View
|
||||||
|
className="rounded-2xl p-4 mt-2"
|
||||||
|
style={{ backgroundColor: hasSettlement ? "#dbeafe" : "#dcfce7" }}
|
||||||
|
>
|
||||||
|
{hasSettlement && settlement.settlement !== null ? (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-medium mb-1"
|
||||||
|
style={{ color: hasSettlement ? "#1d4ed8" : "#15803d" }}
|
||||||
|
>
|
||||||
|
{t("trips.settlement.owes", {
|
||||||
|
from: settlement.settlement.fromName,
|
||||||
|
to: settlement.settlement.toName,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{ color: hasSettlement ? "#1e40af" : "#166534" }}
|
||||||
|
>
|
||||||
|
{formatEur(settlement.settlement.amount)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color="#15803d" />
|
||||||
|
<Text className="text-sm font-medium" style={{ color: "#15803d" }}>
|
||||||
|
{t("trips.settlement.balanced")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="h-4" />
|
||||||
|
|
||||||
|
{/* Confirm button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onConfirm}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded-2xl py-4 items-center active:opacity-80"
|
||||||
|
style={{ backgroundColor: ACCENT }}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold text-white">
|
||||||
|
{t("trips.settlement.closeTrip")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Cancel text button */}
|
||||||
|
<Pressable onPress={onClose} className="py-3 items-center active:opacity-70">
|
||||||
|
<Text className="text-sm text-gray-500">{t("common.cancel")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add Expense Modal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AddExpenseModal({
|
||||||
|
tripId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
tripId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const userId = useAuthStore((s) => s.user?.id ?? "");
|
||||||
|
const { data: membersData } = useHouseholdMembers();
|
||||||
|
const members = membersData?.members ?? [];
|
||||||
|
const { mutate: createExpense, isPending } = useCreateTripExpense(tripId);
|
||||||
|
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [amountStr, setAmountStr] = useState("0");
|
||||||
|
const [category, setCategory] = useState<ExpenseCategory>("sonstiges");
|
||||||
|
const [paidBy, setPaidBy] = useState(userId);
|
||||||
|
const [date, setDate] = useState(todayIso());
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
function handleNumpadKey_(key: string) {
|
||||||
|
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = parseAmountStr(amountStr);
|
||||||
|
const canSave = label.trim().length > 0 && amount > 0 && date.length === 10;
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!canSave) return;
|
||||||
|
|
||||||
|
const input: CreateTripExpenseInput = {
|
||||||
|
label: label.trim(),
|
||||||
|
amount,
|
||||||
|
category,
|
||||||
|
paidBy,
|
||||||
|
date,
|
||||||
|
...(note.trim() ? { note: note.trim() } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
createExpense(input, { onSuccess: onClose });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<ModalHeader
|
||||||
|
title={t("trips.newExpense")}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel={t("common.cancel")}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t("common.save")}
|
||||||
|
saveDisabled={!canSave}
|
||||||
|
saveLoading={isPending}
|
||||||
|
saveColor={ACCENT}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView className="flex-1" keyboardShouldPersistTaps="handled">
|
||||||
|
{/* Amount display */}
|
||||||
|
<View className="items-center py-5">
|
||||||
|
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mt-1">{t("trips.spent")}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Numpad */}
|
||||||
|
<Numpad onKeyPress={handleNumpadKey_} />
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<View className="px-4 mt-4 gap-3">
|
||||||
|
{/* Label */}
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t("trips.label")}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={label}
|
||||||
|
onChangeText={setLabel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category chips */}
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="-mx-4 px-4">
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
{CATEGORY_ORDER.map((cat) => {
|
||||||
|
const selected = category === cat;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={cat}
|
||||||
|
onPress={() => setCategory(cat)}
|
||||||
|
className="flex-row items-center gap-1.5 px-3 py-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: selected ? ACCENT : "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={CATEGORY_ICONS[cat]}
|
||||||
|
size={14}
|
||||||
|
color={selected ? "#fff" : "#6b7280"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: selected ? "#fff" : "#4b5563" }}
|
||||||
|
>
|
||||||
|
{t(`trips.categories.${cat}`)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Paid by */}
|
||||||
|
{members.length > 1 && (
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
{members.map((m) => {
|
||||||
|
const selected = paidBy === m.userId;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={m.userId}
|
||||||
|
onPress={() => setPaidBy(m.userId)}
|
||||||
|
className="flex-1 py-2.5 rounded-xl items-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: selected ? ACCENT : "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: selected ? "#fff" : "#4b5563" }}
|
||||||
|
>
|
||||||
|
{m.name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={date}
|
||||||
|
onChangeText={setDate}
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
maxLength={10}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t("trips.note")}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="h-8" />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Detail Screen ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function TripDetailScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data: summary, isLoading: summaryLoading } = useTrip(id);
|
||||||
|
const { data: expenses = [], isLoading: expensesLoading } = useTripExpenses(id);
|
||||||
|
const { data: membersData } = useHouseholdMembers();
|
||||||
|
const { mutate: deleteExpense } = useDeleteTripExpense(id);
|
||||||
|
const { mutate: completeTrip, isPending: completing } = useCompleteTrip();
|
||||||
|
|
||||||
|
const [showAddExpense, setShowAddExpense] = useState(false);
|
||||||
|
const [showSettlementModal, setShowSettlementModal] = useState(false);
|
||||||
|
const [settlementPreview, setSettlementPreview] = useState<TripSettlement | null>(null);
|
||||||
|
const [isLoadingSettlement, setIsLoadingSettlement] = useState(false);
|
||||||
|
|
||||||
|
const members = membersData?.members ?? [];
|
||||||
|
|
||||||
|
function getMemberName(userId: string): string {
|
||||||
|
return members.find((m) => m.userId === userId)?.name ?? userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteExpense(expenseId: string) {
|
||||||
|
Alert.alert(
|
||||||
|
t("common.delete"),
|
||||||
|
t("common.confirm") + "?",
|
||||||
|
[
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.delete"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteExpense(expenseId),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
if (!summary) return;
|
||||||
|
|
||||||
|
if (expenses.length === 0) {
|
||||||
|
Alert.alert(
|
||||||
|
t("trips.settlement.title"),
|
||||||
|
t("trips.settlement.noExpenses"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingSettlement(true);
|
||||||
|
try {
|
||||||
|
const { apiRequest } = await import("@/src/lib/api-client");
|
||||||
|
const result = await apiRequest<{ settlement: TripSettlement }>(
|
||||||
|
`/api/trips/${id}/settlement`,
|
||||||
|
);
|
||||||
|
setSettlementPreview(result.settlement);
|
||||||
|
setShowSettlementModal(true);
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t("common.error"), t("common.error"));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingSettlement(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmComplete() {
|
||||||
|
completeTrip(id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowSettlementModal(false);
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summaryLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50 items-center justify-center">
|
||||||
|
<Text className="text-gray-400">Nicht gefunden</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { trip, totalSpent, remaining, byCategory } = summary;
|
||||||
|
const isActive = trip.status === "active";
|
||||||
|
|
||||||
|
type ListSection =
|
||||||
|
| { key: "closed-banner" }
|
||||||
|
| { key: "budget" }
|
||||||
|
| { key: "category" }
|
||||||
|
| { key: "expense-header" }
|
||||||
|
| { key: `expense-${string}`; expense: TripExpense };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [];
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
sections.push({ key: "closed-banner" });
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
{ key: "budget" },
|
||||||
|
{ key: "category" },
|
||||||
|
{ key: "expense-header" },
|
||||||
|
...expenses.map(
|
||||||
|
(e): ListSection => ({ key: `expense-${e.id}`, expense: e }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderSection({ item }: { item: ListSection }) {
|
||||||
|
if (item.key === "closed-banner") {
|
||||||
|
return (
|
||||||
|
<ClosedBanner
|
||||||
|
settlementAmount={trip.settlementAmount}
|
||||||
|
settlementFromUserId={trip.settlementFromUserId}
|
||||||
|
settlementToUserId={trip.settlementToUserId}
|
||||||
|
getMemberName={getMemberName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.key === "budget") {
|
||||||
|
return (
|
||||||
|
<BudgetSummaryCard
|
||||||
|
budget={trip.budget}
|
||||||
|
totalSpent={totalSpent}
|
||||||
|
remaining={remaining}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.key === "category") {
|
||||||
|
return <CategorySection byCategory={byCategory} budget={trip.budget} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.key === "expense-header") {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center justify-between px-4 pt-5 pb-2">
|
||||||
|
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||||
|
Ausgaben
|
||||||
|
</Text>
|
||||||
|
{isActive && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAddExpense(true)}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full active:opacity-70"
|
||||||
|
style={{ backgroundColor: ACCENT }}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={14} color="#fff" />
|
||||||
|
<Text className="text-xs font-semibold text-white">{t("trips.newExpense")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.key.startsWith("expense-") && "expense" in item) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mx-4 bg-white rounded-xl mb-2"
|
||||||
|
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<ExpenseRow
|
||||||
|
expense={item.expense}
|
||||||
|
memberName={getMemberName(item.expense.paidBy)}
|
||||||
|
onDelete={() => handleDeleteExpense(item.expense.id)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeDisabled = completing || isLoadingSettlement || expenses.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
paddingTop: insets.top + 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
className="px-4 pb-3 flex-row items-center"
|
||||||
|
>
|
||||||
|
<Pressable onPress={() => router.back()} className="mr-3 p-1">
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-bold text-gray-900">{trip.name}</Text>
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{trip.destination ? `${trip.destination} · ` : ""}
|
||||||
|
{formatDateRange(trip.startDate, trip.endDate)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void handleComplete()}
|
||||||
|
disabled={completeDisabled}
|
||||||
|
className="flex-row items-center gap-1.5 px-3 py-2 rounded-xl active:opacity-70"
|
||||||
|
style={{
|
||||||
|
backgroundColor: completeDisabled ? "#e5e7eb" : "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{completing || isLoadingSettlement ? (
|
||||||
|
<ActivityIndicator size="small" color="#6b7280" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-circle-outline"
|
||||||
|
size={16}
|
||||||
|
color={expenses.length === 0 ? "#d1d5db" : "#6b7280"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-semibold"
|
||||||
|
style={{ color: expenses.length === 0 ? "#d1d5db" : "#6b7280" }}
|
||||||
|
>
|
||||||
|
{t("trips.complete")}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{expensesLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={sections}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
renderItem={renderSection}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
|
||||||
|
ListEmptyComponent={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
{isActive && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAddExpense(true)}
|
||||||
|
style={{ backgroundColor: ACCENT, bottom: insets.bottom + 20 }}
|
||||||
|
className="absolute right-6 w-14 h-14 rounded-full items-center justify-center shadow-lg active:opacity-80"
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={28} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddExpense && (
|
||||||
|
<AddExpenseModal tripId={id} onClose={() => setShowAddExpense(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSettlementModal && settlementPreview !== null && (
|
||||||
|
<SettlementModal
|
||||||
|
settlement={settlementPreview}
|
||||||
|
onConfirm={handleConfirmComplete}
|
||||||
|
onClose={() => setShowSettlementModal(false)}
|
||||||
|
isPending={completing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
427
apps/native/app/(app)/urlaub/index.tsx
Normal file
427
apps/native/app/(app)/urlaub/index.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { EmptyState } from "@/src/components/ui/EmptyState";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { TAB_COLORS } from "@/src/constants/colors";
|
||||||
|
import {
|
||||||
|
useTrips,
|
||||||
|
useCreateTrip,
|
||||||
|
type Trip,
|
||||||
|
type CreateTripInput,
|
||||||
|
} from "@/src/hooks/useTrips";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const ACCENT = TAB_COLORS.shopping; // green #16A34A
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDateRange(startDate: string, endDate: string): string {
|
||||||
|
const fmt = (d: string) => {
|
||||||
|
const parts = d.split("-");
|
||||||
|
return `${parts[2]}.${parts[1]}.${parts[0]?.slice(2)}`;
|
||||||
|
};
|
||||||
|
return `${fmt(startDate)} – ${fmt(endDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBudgetColor(remaining: number, budget: number): string {
|
||||||
|
if (remaining <= 0) return "#dc2626"; // red
|
||||||
|
if (remaining < budget * 0.1) return "#ea580c"; // orange
|
||||||
|
return ACCENT; // green
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress Bar ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProgressBar({
|
||||||
|
spent,
|
||||||
|
budget,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
spent: number;
|
||||||
|
budget: number;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
const ratio = budget > 0 ? Math.min(spent / budget, 1) : 0;
|
||||||
|
return (
|
||||||
|
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden mt-2">
|
||||||
|
<View
|
||||||
|
style={{ width: `${ratio * 100}%`, backgroundColor: color }}
|
||||||
|
className="h-full rounded-full"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active Trip Card ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ActiveTripCard({ trip, onPress }: { trip: Trip; onPress: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const remaining = trip.budget - trip.spent;
|
||||||
|
const color = getBudgetColor(remaining, trip.budget);
|
||||||
|
const isOver = remaining <= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className="mx-4 mb-3 bg-white rounded-2xl p-4 active:opacity-80"
|
||||||
|
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-start justify-between mb-1">
|
||||||
|
<View className="flex-1 mr-2">
|
||||||
|
<Text className="text-base font-semibold text-gray-900">{trip.name}</Text>
|
||||||
|
{trip.destination && (
|
||||||
|
<Text className="text-xs text-gray-400 mt-0.5">{trip.destination}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: `${ACCENT}18` }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-medium" style={{ color: ACCENT }}>
|
||||||
|
{t("trips.active")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-xs text-gray-400 mb-2">
|
||||||
|
{formatDateRange(trip.startDate, trip.endDate)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ProgressBar spent={trip.spent} budget={trip.budget} color={color} />
|
||||||
|
|
||||||
|
<View className="flex-row justify-between mt-2">
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{t("trips.spent")}: {formatEur(trip.spent)}
|
||||||
|
</Text>
|
||||||
|
{isOver ? (
|
||||||
|
<Text className="text-xs font-semibold" style={{ color: "#dc2626" }}>
|
||||||
|
{t("trips.overBudget", { amount: formatEur(Math.abs(remaining)) })}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="text-xs font-semibold" style={{ color }}>
|
||||||
|
{t("trips.remaining")}: {formatEur(remaining)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between mt-1">
|
||||||
|
<Text className="text-xs text-gray-300">{t("trips.budget")}: {formatEur(trip.budget)}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Past Trip Row ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PastTripRow({ trip, onPress }: { trip: Trip; onPress: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasSettlement =
|
||||||
|
trip.settlementAmount !== null && trip.settlementAmount > 0.01;
|
||||||
|
const isBalanced =
|
||||||
|
trip.settlementAmount !== null && trip.settlementAmount <= 0.01;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className="flex-row items-center px-4 py-3 bg-white active:opacity-80"
|
||||||
|
style={{ borderBottomWidth: 1, borderBottomColor: "#f9fafb" }}
|
||||||
|
>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-gray-700">{trip.name}</Text>
|
||||||
|
{trip.destination && (
|
||||||
|
<Text className="text-xs text-gray-400">{trip.destination}</Text>
|
||||||
|
)}
|
||||||
|
{hasSettlement && (
|
||||||
|
<Text className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{t("trips.settlement.closedBanner")} · {formatEur(trip.settlementAmount ?? 0)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{isBalanced && (
|
||||||
|
<Text className="text-xs mt-0.5" style={{ color: "#16a34a" }}>
|
||||||
|
{t("trips.settlement.balanced")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="items-end mr-3">
|
||||||
|
<Text className="text-sm font-semibold text-gray-600">{formatEur(trip.spent)}</Text>
|
||||||
|
<Text className="text-xs text-gray-400">{formatDateRange(trip.startDate, trip.endDate)}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-gray-500">{t("trips.completed")}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Trip Modal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CreateTripModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate: createTrip, isPending } = useCreateTrip();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [destination, setDestination] = useState("");
|
||||||
|
const [budgetStr, setBudgetStr] = useState("");
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
|
|
||||||
|
const canSave =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
parseFloat(budgetStr.replace(",", ".")) > 0 &&
|
||||||
|
startDate.length === 10 &&
|
||||||
|
endDate.length === 10;
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const budget = parseFloat(budgetStr.replace(",", "."));
|
||||||
|
if (!canSave || isNaN(budget)) return;
|
||||||
|
|
||||||
|
const input: CreateTripInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
budget,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
...(destination.trim() ? { destination: destination.trim() } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
createTrip(input, { onSuccess: onClose });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<ModalHeader
|
||||||
|
title={t("trips.new")}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel={t("common.cancel")}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t("common.create")}
|
||||||
|
saveDisabled={!canSave}
|
||||||
|
saveLoading={isPending}
|
||||||
|
saveColor={ACCENT}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
|
||||||
|
{/* Name */}
|
||||||
|
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||||||
|
{t("trips.name")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
placeholder="z.B. Sommerurlaub Italien"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Destination */}
|
||||||
|
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||||||
|
{t("trips.destination")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
placeholder="z.B. Rom, Italien"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={destination}
|
||||||
|
onChangeText={setDestination}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Budget */}
|
||||||
|
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||||||
|
{t("trips.budget")} (€) *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
placeholder="z.B. 2000"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={budgetStr}
|
||||||
|
onChangeText={setBudgetStr}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Start Date */}
|
||||||
|
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||||||
|
{t("trips.startDate")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={startDate}
|
||||||
|
onChangeText={setStartDate}
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
maxLength={10}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* End Date */}
|
||||||
|
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||||||
|
{t("trips.endDate")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={endDate}
|
||||||
|
onChangeText={setEndDate}
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
maxLength={10}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Screen ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SectionItem =
|
||||||
|
| { type: "header"; label: string }
|
||||||
|
| { type: "active-trip"; trip: Trip }
|
||||||
|
| { type: "past-trip"; trip: Trip }
|
||||||
|
| { type: "empty" };
|
||||||
|
|
||||||
|
export default function UrlaubScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: trips = [], isLoading, refetch, isRefetching } = useTrips();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
const activeTrips = trips.filter((t) => t.status === "active");
|
||||||
|
const pastTrips = trips.filter((t) => t.status === "completed");
|
||||||
|
|
||||||
|
// Build flat list items for sectioned rendering
|
||||||
|
const listItems: SectionItem[] = [];
|
||||||
|
|
||||||
|
if (activeTrips.length > 0) {
|
||||||
|
listItems.push({ type: "header", label: t("trips.active") });
|
||||||
|
activeTrips.forEach((trip) => listItems.push({ type: "active-trip", trip }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pastTrips.length > 0) {
|
||||||
|
listItems.push({ type: "header", label: t("trips.past") });
|
||||||
|
pastTrips.forEach((trip) => listItems.push({ type: "past-trip", trip }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trips.length === 0 && !isLoading) {
|
||||||
|
listItems.push({ type: "empty" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItem({ item }: { item: SectionItem }) {
|
||||||
|
if (item.type === "header") {
|
||||||
|
return (
|
||||||
|
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-4 pb-2">
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "active-trip") {
|
||||||
|
return (
|
||||||
|
<ActiveTripCard
|
||||||
|
trip={item.trip}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/(app)/urlaub/[id]",
|
||||||
|
params: { id: item.trip.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "past-trip") {
|
||||||
|
return (
|
||||||
|
<PastTripRow
|
||||||
|
trip={item.trip}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/(app)/urlaub/[id]",
|
||||||
|
params: { id: item.trip.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty state
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="airplane-outline"
|
||||||
|
title={t("trips.noTrips")}
|
||||||
|
subtitle={t("trips.noTripsHint")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
paddingTop: insets.top + 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
className="px-4 pb-3 flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Pressable onPress={() => router.push("/(app)/mehr")} className="mr-1 p-1">
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-xl font-bold text-gray-900">{t("trips.title")}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowCreate(true)}
|
||||||
|
className="w-9 h-9 rounded-full items-center justify-center active:opacity-70"
|
||||||
|
style={{ backgroundColor: ACCENT }}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={22} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={listItems}
|
||||||
|
keyExtractor={(item, index) => {
|
||||||
|
if (item.type === "header") return `header-${item.label}`;
|
||||||
|
if (item.type === "active-trip" || item.type === "past-trip")
|
||||||
|
return item.trip.id;
|
||||||
|
return `empty-${index}`;
|
||||||
|
}}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={
|
||||||
|
trips.length === 0
|
||||||
|
? { flex: 1, paddingBottom: insets.bottom + 16 }
|
||||||
|
: { paddingBottom: insets.bottom + 16, paddingTop: 4 }
|
||||||
|
}
|
||||||
|
refreshing={isRefetching}
|
||||||
|
onRefresh={() => void refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && <CreateTripModal onClose={() => setShowCreate(false)} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/native/app/(auth)/_layout.tsx
Normal file
14
apps/native/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="login" />
|
||||||
|
<Stack.Screen name="register" />
|
||||||
|
<Stack.Screen name="onboarding" />
|
||||||
|
<Stack.Screen name="setup" />
|
||||||
|
<Stack.Screen name="forgot-password" />
|
||||||
|
<Stack.Screen name="reset-password" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
apps/native/app/(auth)/forgot-password.tsx
Normal file
100
apps/native/app/(auth)/forgot-password.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { authClient } from "@/src/lib/auth-client";
|
||||||
|
|
||||||
|
export default function ForgotPasswordScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!email.trim()) return;
|
||||||
|
setIsPending(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await authClient.requestPasswordReset({
|
||||||
|
email: email.trim(),
|
||||||
|
redirectTo: "haushaltsApp://reset-password",
|
||||||
|
});
|
||||||
|
setIsPending(false);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? t('common.error'));
|
||||||
|
} else {
|
||||||
|
setSent(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-white"
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
>
|
||||||
|
<View style={{ paddingTop: insets.top }} className="px-6 pt-4 pb-2 flex-row items-center">
|
||||||
|
<Pressable onPress={() => router.back()} className="p-1 mr-2 active:opacity-50">
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 px-6 pt-8">
|
||||||
|
<Text className="text-2xl font-bold text-gray-900 mb-2">{t('forgotPassword.title')}</Text>
|
||||||
|
<Text className="text-sm text-gray-500 mb-8">{t('forgotPassword.subtitle')}</Text>
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<View className="bg-green-50 rounded-2xl p-5 items-center" style={{ borderWidth: 1, borderColor: "#bbf7d0" }}>
|
||||||
|
<Ionicons name="checkmark-circle" size={48} color="#16a34a" style={{ marginBottom: 12 }} />
|
||||||
|
<Text className="text-base font-semibold text-green-800 text-center mb-1">{t('forgotPassword.sentTitle')}</Text>
|
||||||
|
<Text className="text-sm text-green-600 text-center">{t('forgotPassword.sentHint')}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">{t('login.emailLabel')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
placeholder={t('login.emailPlaceholder')}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text className="text-sm text-red-500 mb-4">{error}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSend}
|
||||||
|
disabled={isPending || !email.trim()}
|
||||||
|
className="rounded-2xl py-4 items-center active:opacity-80"
|
||||||
|
style={{ backgroundColor: isPending || !email.trim() ? "#e5e7eb" : "#2563EB" }}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold" style={{ color: !email.trim() ? "#9ca3af" : "#fff" }}>
|
||||||
|
{t('forgotPassword.sendButton')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
apps/native/app/(auth)/login.tsx
Normal file
182
apps/native/app/(auth)/login.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { signIn, authClient } from "@/src/lib/auth-client";
|
||||||
|
import { apiRequest } from "@/src/lib/api-client";
|
||||||
|
import { useAuthStore, type Household } from "@/src/stores/auth.store";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as AppleAuthentication from "expo-apple-authentication";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { setUser, setHouseholds, setActiveHousehold } = useAuthStore();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleEmailSignIn() {
|
||||||
|
if (!email || !password) {
|
||||||
|
setError(t('login.fillAllFields'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await signIn.email({ email, password });
|
||||||
|
if (result.error) {
|
||||||
|
const msg = result.error.message ?? "";
|
||||||
|
if (msg.toLowerCase().includes("email") && msg.toLowerCase().includes("verif")) {
|
||||||
|
router.push({ pathname: "/(auth)/verify-email", params: { email } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(msg || t('login.signInError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.data?.user) setUser(result.data.user);
|
||||||
|
try {
|
||||||
|
const { households } = await apiRequest<{ households: Household[] }>("/api/households");
|
||||||
|
setHouseholds(households);
|
||||||
|
if (households.length > 0) setActiveHousehold(households[0].id);
|
||||||
|
} catch {
|
||||||
|
// households will be loaded on next app start
|
||||||
|
}
|
||||||
|
router.replace("/(app)/haushalt");
|
||||||
|
} catch {
|
||||||
|
setError(t('login.signInError'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAppleSignIn() {
|
||||||
|
try {
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!credential.identityToken) return;
|
||||||
|
const result = await authClient.signIn.social({
|
||||||
|
provider: "apple",
|
||||||
|
idToken: { token: credential.identityToken },
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? t('common.error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// session is handled by authClient interceptor
|
||||||
|
router.replace("/(app)/haushalt");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as { code?: string }).code !== "ERR_CANCELED") {
|
||||||
|
setError(t('login.appleSignInError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className="flex-1 bg-white"
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center px-6">
|
||||||
|
<Text className="mb-2 text-3xl font-bold text-gray-900">
|
||||||
|
{t('login.welcome')}
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-8 text-base text-gray-500">
|
||||||
|
{t('login.subtitle')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View className="mb-4 rounded-lg bg-red-50 p-3">
|
||||||
|
<Text className="text-sm text-red-600">{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Platform.OS === "ios" && (
|
||||||
|
<>
|
||||||
|
<AppleAuthentication.AppleAuthenticationButton
|
||||||
|
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
|
||||||
|
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||||
|
cornerRadius={12}
|
||||||
|
style={{ width: "100%", height: 50 }}
|
||||||
|
onPress={handleAppleSignIn}
|
||||||
|
/>
|
||||||
|
<View className="flex-row items-center gap-3 my-4">
|
||||||
|
<View className="flex-1 h-px bg-gray-200" />
|
||||||
|
<Text className="text-xs text-gray-400">{t('common.or')}</Text>
|
||||||
|
<View className="flex-1 h-px bg-gray-200" />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="mb-1.5 text-sm font-medium text-gray-700">
|
||||||
|
{t('login.emailLabel')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('login.emailPlaceholder')}
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="mb-1.5 text-sm font-medium text-gray-700">
|
||||||
|
{t('login.passwordLabel')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('login.passwordPlaceholder')}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="password"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleEmailSignIn}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="mb-3 items-center rounded-xl bg-blue-600 py-4 active:opacity-80"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold text-white">{t('login.signIn')}</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push("/(auth)/forgot-password")}
|
||||||
|
className="mb-6 items-center py-2 active:opacity-60"
|
||||||
|
>
|
||||||
|
<Text className="text-sm text-blue-600">{t('login.forgotPassword')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View className="flex-row justify-center">
|
||||||
|
<Text className="text-sm text-gray-500">{t('login.noAccount')} </Text>
|
||||||
|
<Pressable onPress={() => router.push("/(auth)/register")}>
|
||||||
|
<Text className="text-sm font-semibold text-blue-600">
|
||||||
|
{t('login.register')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
apps/native/app/(auth)/onboarding.tsx
Normal file
363
apps/native/app/(auth)/onboarding.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { authClient } from "@/src/lib/auth-client";
|
||||||
|
import { apiRequest } from "@/src/lib/api-client";
|
||||||
|
import { useAuthStore } from "@/src/stores/auth.store";
|
||||||
|
import { useJoinWithCode } from "@/src/hooks/useInvite";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
// Which top-level path the user is on
|
||||||
|
type Mode = "choose" | "create" | "join";
|
||||||
|
|
||||||
|
// ── OTP Box Input ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OtpInput({
|
||||||
|
onComplete,
|
||||||
|
hasError,
|
||||||
|
}: {
|
||||||
|
onComplete: (code: string) => void;
|
||||||
|
hasError: boolean;
|
||||||
|
}) {
|
||||||
|
const [digits, setDigits] = useState<string[]>(["", "", "", "", "", ""]);
|
||||||
|
const refs = useRef<Array<TextInput | null>>([]);
|
||||||
|
|
||||||
|
function handleChange(text: string, index: number) {
|
||||||
|
// Handle paste: if pasted text fills all 6 slots
|
||||||
|
if (text.length === 6) {
|
||||||
|
const upper = text.toUpperCase();
|
||||||
|
const filled = upper.split("").slice(0, 6);
|
||||||
|
setDigits(filled);
|
||||||
|
refs.current[5]?.focus();
|
||||||
|
onComplete(upper);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle paste into a single box that is actually 2 chars (current + new char)
|
||||||
|
const char = text.slice(-1).toUpperCase();
|
||||||
|
const newDigits = [...digits];
|
||||||
|
newDigits[index] = char;
|
||||||
|
setDigits(newDigits);
|
||||||
|
|
||||||
|
if (char && index < 5) {
|
||||||
|
refs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDigits.every((d) => d !== "")) {
|
||||||
|
onComplete(newDigits.join(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyPress(key: string, index: number) {
|
||||||
|
if (key === "Backspace") {
|
||||||
|
const newDigits = [...digits];
|
||||||
|
if (digits[index] === "" && index > 0) {
|
||||||
|
newDigits[index - 1] = "";
|
||||||
|
setDigits(newDigits);
|
||||||
|
refs.current[index - 1]?.focus();
|
||||||
|
} else {
|
||||||
|
newDigits[index] = "";
|
||||||
|
setDigits(newDigits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", gap: 8, justifyContent: "center" }}>
|
||||||
|
{digits.map((digit, i) => (
|
||||||
|
<TextInput
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
refs.current[i] = el;
|
||||||
|
}}
|
||||||
|
value={digit}
|
||||||
|
onChangeText={(text) => handleChange(text, i)}
|
||||||
|
onKeyPress={({ nativeEvent }) => handleKeyPress(nativeEvent.key, i)}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
maxLength={2}
|
||||||
|
selectTextOnFocus
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: hasError ? "#dc2626" : digit ? "#2563EB" : "#e5e7eb",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#111827",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Screen ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { setActiveHousehold, setHouseholds, households } = useAuthStore();
|
||||||
|
const [mode, setMode] = useState<Mode>("choose");
|
||||||
|
const [householdName, setHouseholdName] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingCode, setPendingCode] = useState("");
|
||||||
|
|
||||||
|
const { mutate: joinWithCode, isPending: isJoining } = useJoinWithCode();
|
||||||
|
|
||||||
|
async function handleCreateHousehold() {
|
||||||
|
if (!householdName.trim()) {
|
||||||
|
setError(t('onboarding.enterHouseholdName'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await authClient.organization.create({
|
||||||
|
name: householdName.trim(),
|
||||||
|
slug: householdName.trim().toLowerCase().replace(/\s+/g, "-"),
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? t('onboarding.createError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.data?.id) {
|
||||||
|
const organizationId = result.data.id;
|
||||||
|
const newHousehold = { id: organizationId, name: householdName.trim(), role: "owner" };
|
||||||
|
// Bridge: create households row + seed default categories
|
||||||
|
await apiRequest("/api/households/setup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-household-id": organizationId },
|
||||||
|
});
|
||||||
|
// Append to existing list, keep current active household
|
||||||
|
setHouseholds([...households, newHousehold]);
|
||||||
|
// Only switch if this is the first household (initial onboarding)
|
||||||
|
if (households.length === 0) {
|
||||||
|
setActiveHousehold(organizationId);
|
||||||
|
router.replace("/(auth)/setup");
|
||||||
|
} else {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t('onboarding.createError'));
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCodeComplete(code: string) {
|
||||||
|
setPendingCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJoinSubmit() {
|
||||||
|
const code = pendingCode.trim().toUpperCase();
|
||||||
|
if (code.length !== 6) {
|
||||||
|
setError(t('onboarding.enterInviteCode'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
joinWithCode(code, {
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
const newHousehold = {
|
||||||
|
id: data.householdId,
|
||||||
|
name: data.householdName,
|
||||||
|
role: "member",
|
||||||
|
};
|
||||||
|
setHouseholds([...households, newHousehold]);
|
||||||
|
setActiveHousehold(data.householdId);
|
||||||
|
router.replace("/(app)/haushalt");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err.message ?? t('invite.invalidCode');
|
||||||
|
if (msg.toLowerCase().includes("already")) {
|
||||||
|
setError(t('invite.alreadyMember'));
|
||||||
|
} else {
|
||||||
|
setError(t('invite.invalidCode'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Choose screen ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (mode === "choose") {
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className="flex-1 bg-white"
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center px-6">
|
||||||
|
<Text className="mb-2 text-3xl font-bold text-gray-900">
|
||||||
|
{t('invite.setupTitle')}
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-10 text-base text-gray-500">
|
||||||
|
{t('onboarding.setupSubtitle')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Create new */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { setError(null); setMode("create"); }}
|
||||||
|
className="mb-4 rounded-xl border border-gray-200 bg-white p-5 active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-4">
|
||||||
|
<View className="w-11 h-11 rounded-full bg-blue-50 items-center justify-center">
|
||||||
|
<Ionicons name="add-circle-outline" size={24} color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-semibold text-gray-900">
|
||||||
|
{t('invite.createNew')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-400">{t('invite.createNewSub')}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Join with code */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { setError(null); setMode("join"); }}
|
||||||
|
className="rounded-xl border border-gray-200 bg-white p-5 active:opacity-70"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-4">
|
||||||
|
<View className="w-11 h-11 rounded-full bg-blue-50 items-center justify-center">
|
||||||
|
<Ionicons name="key-outline" size={24} color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-semibold text-gray-900">
|
||||||
|
{t('invite.enterCode')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-400">{t('invite.enterCodeSub')}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create screen ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (mode === "create") {
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className="flex-1 bg-white"
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center px-6">
|
||||||
|
{/* Back */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { setError(null); setMode("choose"); }}
|
||||||
|
className="mb-6 flex-row items-center gap-1 self-start active:opacity-60"
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={18} color="#6b7280" />
|
||||||
|
<Text className="text-sm text-gray-500">{t('common.back')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text className="mb-2 text-3xl font-bold text-gray-900">
|
||||||
|
{t('onboarding.setupTitle')}
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-8 text-base text-gray-500">
|
||||||
|
{t('onboarding.setupSubtitle')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View className="mb-4 rounded-lg bg-red-50 p-3">
|
||||||
|
<Text className="text-sm text-red-600">{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="mb-1.5 text-sm font-medium text-gray-700">
|
||||||
|
{t('onboarding.householdNameLabel')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('onboarding.householdNamePlaceholder')}
|
||||||
|
value={householdName}
|
||||||
|
onChangeText={setHouseholdName}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCreateHousehold}
|
||||||
|
disabled={isCreating}
|
||||||
|
className="items-center rounded-xl bg-blue-600 py-4 active:opacity-80"
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold text-white">
|
||||||
|
{t('onboarding.createHousehold')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Join screen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className="flex-1 bg-white"
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center px-6">
|
||||||
|
{/* Back */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { setError(null); setPendingCode(""); setMode("choose"); }}
|
||||||
|
className="mb-6 flex-row items-center gap-1 self-start active:opacity-60"
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={18} color="#6b7280" />
|
||||||
|
<Text className="text-sm text-gray-500">{t('common.back')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text className="mb-2 text-3xl font-bold text-gray-900">
|
||||||
|
{t('invite.joinTitle')}
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-8 text-base text-gray-500">
|
||||||
|
{t('invite.joinHint')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<OtpInput onComplete={handleCodeComplete} hasError={!!error} />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View className="mt-4 rounded-lg bg-red-50 p-3">
|
||||||
|
<Text className="text-sm text-red-600 text-center">{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleJoinSubmit}
|
||||||
|
disabled={isJoining || pendingCode.length !== 6}
|
||||||
|
className="mt-8 items-center rounded-xl bg-blue-600 py-4 active:opacity-80 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isJoining ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold text-white">
|
||||||
|
{t('invite.joinButton')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
apps/native/app/(auth)/register.tsx
Normal file
181
apps/native/app/(auth)/register.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { signUp, authClient } from "@/src/lib/auth-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as AppleAuthentication from "expo-apple-authentication";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleEmailRegister() {
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
setError("Bitte alle Felder ausfüllen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Passwort muss mindestens 8 Zeichen lang sein");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await signUp.email({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackURL: "haushaltsApp://onboarding",
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? "Registrierung fehlgeschlagen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Email verification required — don't set user/session yet
|
||||||
|
router.replace({ pathname: "/(auth)/verify-email", params: { email } });
|
||||||
|
} catch {
|
||||||
|
setError("Registrierung fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAppleRegister() {
|
||||||
|
try {
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!credential.identityToken) return;
|
||||||
|
const result = await authClient.signIn.social({
|
||||||
|
provider: "apple",
|
||||||
|
idToken: { token: credential.identityToken },
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? t('common.error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// session is handled by authClient interceptor
|
||||||
|
router.replace("/(app)/haushalt");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as { code?: string }).code !== "ERR_CANCELED") {
|
||||||
|
setError(t('login.appleSignInError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className="flex-1 bg-white"
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center px-6">
|
||||||
|
<Text className="mb-2 text-3xl font-bold text-gray-900">
|
||||||
|
Konto erstellen
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-8 text-base text-gray-500">
|
||||||
|
Starte deinen Haushalts-Manager
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View className="mb-4 rounded-lg bg-red-50 p-3">
|
||||||
|
<Text className="text-sm text-red-600">{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Platform.OS === "ios" && (
|
||||||
|
<>
|
||||||
|
<AppleAuthentication.AppleAuthenticationButton
|
||||||
|
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_UP}
|
||||||
|
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||||
|
cornerRadius={12}
|
||||||
|
style={{ width: "100%", height: 50 }}
|
||||||
|
onPress={handleAppleRegister}
|
||||||
|
/>
|
||||||
|
<View className="flex-row items-center gap-3 my-4">
|
||||||
|
<View className="flex-1 h-px bg-gray-200" />
|
||||||
|
<Text className="text-xs text-gray-400">{t('common.or')}</Text>
|
||||||
|
<View className="flex-1 h-px bg-gray-200" />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="mb-1.5 text-sm font-medium text-gray-700">Name</Text>
|
||||||
|
<TextInput
|
||||||
|
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder="Dein Name"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="mb-1.5 text-sm font-medium text-gray-700">
|
||||||
|
E-Mail
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="mb-1.5 text-sm font-medium text-gray-700">
|
||||||
|
Passwort
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleEmailRegister}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="mb-3 items-center rounded-xl bg-blue-600 py-4 active:opacity-80"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold text-white">
|
||||||
|
Konto erstellen
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View className="flex-row justify-center">
|
||||||
|
<Text className="text-sm text-gray-500">Bereits ein Konto? </Text>
|
||||||
|
<Pressable onPress={() => router.push("/(auth)/login")}>
|
||||||
|
<Text className="text-sm font-semibold text-blue-600">Anmelden</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
apps/native/app/(auth)/reset-password.tsx
Normal file
104
apps/native/app/(auth)/reset-password.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { authClient } from "@/src/lib/auth-client";
|
||||||
|
|
||||||
|
export default function ResetPasswordScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token } = useLocalSearchParams<{ token: string }>();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mismatch = confirm.length > 0 && password !== confirm;
|
||||||
|
const canSave = password.length >= 8 && password === confirm;
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!canSave || !token) return;
|
||||||
|
setIsPending(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await authClient.resetPassword({ newPassword: password, token });
|
||||||
|
setIsPending(false);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? t('common.error'));
|
||||||
|
} else {
|
||||||
|
setDone(true);
|
||||||
|
setTimeout(() => router.replace("/(auth)/login"), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-white"
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
>
|
||||||
|
<View className="flex-1 px-6" style={{ paddingTop: insets.top + 40 }}>
|
||||||
|
<Text className="text-2xl font-bold text-gray-900 mb-2">{t('resetPassword.title')}</Text>
|
||||||
|
<Text className="text-sm text-gray-500 mb-8">{t('resetPassword.subtitle')}</Text>
|
||||||
|
|
||||||
|
{done ? (
|
||||||
|
<View className="bg-green-50 rounded-2xl p-5 items-center" style={{ borderWidth: 1, borderColor: "#bbf7d0" }}>
|
||||||
|
<Text className="text-base font-semibold text-green-800 text-center">{t('resetPassword.successMessage')}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">{t('resetPassword.newPassword')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">{t('resetPassword.confirmPassword')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className={`bg-gray-50 border rounded-xl px-4 py-3 text-base text-gray-900 mb-1 ${mismatch ? "border-red-300" : "border-gray-200"}`}
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={confirm}
|
||||||
|
onChangeText={setConfirm}
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{mismatch && <Text className="text-xs text-red-500 mb-3">{t('resetPassword.mismatch')}</Text>}
|
||||||
|
{!mismatch && <View className="mb-4" />}
|
||||||
|
|
||||||
|
{error && <Text className="text-sm text-red-500 mb-4">{error}</Text>}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isPending || !canSave}
|
||||||
|
className="rounded-2xl py-4 items-center active:opacity-80"
|
||||||
|
style={{ backgroundColor: !canSave ? "#e5e7eb" : "#2563EB" }}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold" style={{ color: !canSave ? "#9ca3af" : "#fff" }}>
|
||||||
|
{t('resetPassword.saveButton')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
apps/native/app/(auth)/setup.tsx
Normal file
265
apps/native/app/(auth)/setup.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { useUpdateHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const ACCENT = "#2563EB";
|
||||||
|
const SHARE_PRESETS = [50, 60, 75, 100];
|
||||||
|
|
||||||
|
export default function SetupScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate: updateSettings, isPending } = useUpdateHouseholdSettings();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
|
||||||
|
const [ownerName, setOwnerName] = useState("");
|
||||||
|
const [partnerName, setPartnerName] = useState("");
|
||||||
|
const [userSharePercent, setUserSharePercent] = useState(50);
|
||||||
|
const [monthlyBudget, setMonthlyBudget] = useState("400");
|
||||||
|
const [splitChildCosts, setSplitChildCosts] = useState(true);
|
||||||
|
|
||||||
|
function handleSkip() {
|
||||||
|
finalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalize() {
|
||||||
|
const input = {
|
||||||
|
ownerName: ownerName.trim() || "Ich",
|
||||||
|
partnerName: partnerName.trim() || "Partner",
|
||||||
|
userSharePercent,
|
||||||
|
monthlyBudget: parseFloat(monthlyBudget.replace(",", ".")) || 400,
|
||||||
|
splitChildCosts,
|
||||||
|
onboardingComplete: true,
|
||||||
|
};
|
||||||
|
updateSettings(input, {
|
||||||
|
onSuccess: () => router.replace("/(app)/haushalt"),
|
||||||
|
onError: () => router.replace("/(app)/haushalt"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white" style={{ paddingTop: insets.top }}>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<View className="flex-row px-6 pt-4 pb-2 gap-2">
|
||||||
|
{([1, 2, 3, 4] as const).map((s) => (
|
||||||
|
<View
|
||||||
|
key={s}
|
||||||
|
className="flex-1 h-1 rounded-full"
|
||||||
|
style={{ backgroundColor: s <= step ? ACCENT : "#e5e7eb" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Skip */}
|
||||||
|
<View className="flex-row justify-end px-6 py-2">
|
||||||
|
<Pressable onPress={handleSkip} className="py-1 px-2 active:opacity-50">
|
||||||
|
<Text className="text-sm text-gray-400">{t('onboarding.skip')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 px-6"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
>
|
||||||
|
{/* Step 1 — Willkommen */}
|
||||||
|
{step === 1 && (
|
||||||
|
<View className="flex-1 items-center justify-center pt-16">
|
||||||
|
<View
|
||||||
|
className="w-20 h-20 rounded-3xl items-center justify-center mb-6"
|
||||||
|
style={{ backgroundColor: "#dbeafe" }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 40 }}>💰</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-3xl font-bold text-gray-900 text-center mb-3">
|
||||||
|
{t('onboarding.welcome')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-500 text-center leading-6">
|
||||||
|
{t('onboarding.subtitle')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2 — Namen */}
|
||||||
|
{step === 2 && (
|
||||||
|
<View className="pt-8">
|
||||||
|
<Text className="text-2xl font-bold text-gray-900 mb-2">{t('setup.namesTitle')}</Text>
|
||||||
|
<Text className="text-base text-gray-500 mb-8">
|
||||||
|
{t('setup.namesHint')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1.5">{t('onboarding.yourName')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||||
|
placeholder={t('onboarding.yourNamePlaceholder')}
|
||||||
|
value={ownerName}
|
||||||
|
onChangeText={setOwnerName}
|
||||||
|
autoCapitalize="words"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
{t('settings.household.partnerName')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('onboarding.partnerNamePlaceholder')}
|
||||||
|
value={partnerName}
|
||||||
|
onChangeText={setPartnerName}
|
||||||
|
autoCapitalize="words"
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3 — Kostenaufteilung */}
|
||||||
|
{step === 3 && (
|
||||||
|
<View className="pt-8">
|
||||||
|
<Text className="text-2xl font-bold text-gray-900 mb-2">{t('setup.costSplitTitle')}</Text>
|
||||||
|
<Text className="text-base text-gray-500 mb-6">
|
||||||
|
{t('setup.costSplitHint')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Preset buttons */}
|
||||||
|
<View className="flex-row gap-2 mb-6">
|
||||||
|
{SHARE_PRESETS.map((p) => (
|
||||||
|
<Pressable
|
||||||
|
key={p}
|
||||||
|
onPress={() => setUserSharePercent(p)}
|
||||||
|
className="flex-1 py-3 rounded-xl items-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: userSharePercent === p ? ACCENT : "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: userSharePercent === p ? "#fff" : "#374151" }}
|
||||||
|
>
|
||||||
|
{p}%
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<View
|
||||||
|
className="rounded-xl p-4 mb-6"
|
||||||
|
style={{ backgroundColor: "#eff6ff", borderWidth: 1, borderColor: "#bfdbfe" }}
|
||||||
|
>
|
||||||
|
<Text className="text-sm text-blue-700">
|
||||||
|
{t('onboarding.preview', {
|
||||||
|
own: userSharePercent,
|
||||||
|
partner: partnerName.trim() || 'Partner',
|
||||||
|
rest: 100 - userSharePercent,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Monthly budget */}
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
{t('setup.monthlyBudgetLabel')}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 mb-6">
|
||||||
|
<Text className="text-base text-gray-400 mr-2">€</Text>
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 text-base text-gray-900"
|
||||||
|
placeholder="400"
|
||||||
|
value={monthlyBudget}
|
||||||
|
onChangeText={setMonthlyBudget}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Split child costs */}
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
{t('setup.splitChildCostsLabel')}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setSplitChildCosts(true)}
|
||||||
|
className="flex-1 py-3 rounded-xl items-center"
|
||||||
|
style={{ backgroundColor: splitChildCosts ? ACCENT : "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: splitChildCosts ? "#fff" : "#374151" }}
|
||||||
|
>
|
||||||
|
{t('common.yes')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setSplitChildCosts(false)}
|
||||||
|
className="flex-1 py-3 rounded-xl items-center"
|
||||||
|
style={{ backgroundColor: !splitChildCosts ? ACCENT : "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: !splitChildCosts ? "#fff" : "#374151" }}
|
||||||
|
>
|
||||||
|
{t('common.no')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4 — Fertig */}
|
||||||
|
{step === 4 && (
|
||||||
|
<View className="flex-1 items-center justify-center pt-16">
|
||||||
|
<View
|
||||||
|
className="w-20 h-20 rounded-3xl items-center justify-center mb-6"
|
||||||
|
style={{ backgroundColor: "#dcfce7" }}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark-circle" size={44} color="#16a34a" />
|
||||||
|
</View>
|
||||||
|
<Text className="text-3xl font-bold text-gray-900 text-center mb-3">
|
||||||
|
{t('onboarding.done')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-500 text-center leading-6">
|
||||||
|
{t('onboarding.doneHint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Bottom CTA */}
|
||||||
|
<View className="px-6 pb-8" style={{ paddingBottom: insets.bottom + 24 }}>
|
||||||
|
{step < 4 ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setStep(((step + 1) as 1 | 2 | 3 | 4))}
|
||||||
|
className="rounded-2xl py-4 items-center active:opacity-80"
|
||||||
|
style={{ backgroundColor: ACCENT }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-semibold text-white">
|
||||||
|
{step === 1 ? t('onboarding.start') : t('common.next')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={finalize}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded-2xl py-4 items-center active:opacity-80"
|
||||||
|
style={{ backgroundColor: "#16a34a" }}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold text-white">{t('onboarding.startApp')}</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/native/app/(auth)/verify-email.tsx
Normal file
83
apps/native/app/(auth)/verify-email.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { authClient } from "@/src/lib/auth-client";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
export default function VerifyEmailScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { email } = useLocalSearchParams<{ email: string }>();
|
||||||
|
const [isResending, setIsResending] = useState(false);
|
||||||
|
const [resent, setResent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
if (!email) return;
|
||||||
|
setIsResending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await authClient.sendVerificationEmail({ email, callbackURL: "/" });
|
||||||
|
setResent(true);
|
||||||
|
setTimeout(() => setResent(false), 4000);
|
||||||
|
} catch {
|
||||||
|
setError(t('verifyEmail.resendError'));
|
||||||
|
} finally {
|
||||||
|
setIsResending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white items-center justify-center px-6">
|
||||||
|
<View className="w-16 h-16 rounded-full bg-blue-50 items-center justify-center mb-6">
|
||||||
|
<Ionicons name="mail-outline" size={32} color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-2xl font-bold text-gray-900 mb-2 text-center">
|
||||||
|
{t('verifyEmail.title')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-500 mb-2 text-center">
|
||||||
|
{t('verifyEmail.hint')}
|
||||||
|
</Text>
|
||||||
|
{email && (
|
||||||
|
<Text className="text-base font-semibold text-gray-900 mb-8 text-center">
|
||||||
|
{email}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View className="mb-4 rounded-lg bg-red-50 p-3 w-full">
|
||||||
|
<Text className="text-sm text-red-600 text-center">{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resent && (
|
||||||
|
<View className="mb-4 rounded-lg bg-green-50 p-3 w-full">
|
||||||
|
<Text className="text-sm text-green-700 text-center">{t('verifyEmail.resentConfirm')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleResend}
|
||||||
|
disabled={isResending}
|
||||||
|
className="w-full items-center rounded-xl bg-blue-600 py-4 mb-4 active:opacity-80"
|
||||||
|
>
|
||||||
|
{isResending ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-semibold text-white">
|
||||||
|
{t('verifyEmail.resend')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.replace("/(auth)/login")}
|
||||||
|
className="py-2 active:opacity-60"
|
||||||
|
>
|
||||||
|
<Text className="text-sm text-gray-500">{t('verifyEmail.backToLogin')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Tabs } from "expo-router";
|
|
||||||
import { useThemeColor } from "heroui-native";
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const themeColorForeground = useThemeColor("foreground");
|
|
||||||
const themeColorBackground = useThemeColor("background");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: themeColorBackground,
|
|
||||||
},
|
|
||||||
headerTintColor: themeColorForeground,
|
|
||||||
headerTitleStyle: {
|
|
||||||
color: themeColorForeground,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: themeColorBackground,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: "Home",
|
|
||||||
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
|
|
||||||
<Ionicons name="home" size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="two"
|
|
||||||
options={{
|
|
||||||
title: "Explore",
|
|
||||||
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
|
|
||||||
<Ionicons name="compass" size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Card } from "heroui-native";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
|
|
||||||
import { Container } from "@/components/container";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<Container className="p-6">
|
|
||||||
<View className="flex-1 justify-center items-center">
|
|
||||||
<Card variant="secondary" className="p-8 items-center">
|
|
||||||
<Card.Title className="text-3xl mb-2">Tab One</Card.Title>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Card } from "heroui-native";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
|
|
||||||
import { Container } from "@/components/container";
|
|
||||||
|
|
||||||
export default function TabTwo() {
|
|
||||||
return (
|
|
||||||
<Container className="p-6">
|
|
||||||
<View className="flex-1 justify-center items-center">
|
|
||||||
<Card variant="secondary" className="p-8 items-center">
|
|
||||||
<Card.Title className="text-3xl mb-2">TabTwo</Card.Title>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
|
||||||
import { Link } from "expo-router";
|
|
||||||
import { Drawer } from "expo-router/drawer";
|
|
||||||
import { useThemeColor } from "heroui-native";
|
|
||||||
import React, { useCallback } from "react";
|
|
||||||
import { Pressable, Text } from "react-native";
|
|
||||||
|
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
|
||||||
|
|
||||||
function DrawerLayout() {
|
|
||||||
const themeColorForeground = useThemeColor("foreground");
|
|
||||||
const themeColorBackground = useThemeColor("background");
|
|
||||||
|
|
||||||
const renderThemeToggle = useCallback(() => <ThemeToggle />, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
screenOptions={{
|
|
||||||
headerTintColor: themeColorForeground,
|
|
||||||
headerStyle: { backgroundColor: themeColorBackground },
|
|
||||||
headerTitleStyle: {
|
|
||||||
fontWeight: "600",
|
|
||||||
color: themeColorForeground,
|
|
||||||
},
|
|
||||||
headerRight: renderThemeToggle,
|
|
||||||
drawerStyle: { backgroundColor: themeColorBackground },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Drawer.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
headerTitle: "Home",
|
|
||||||
drawerLabel: ({ color, focused }) => (
|
|
||||||
<Text style={{ color: focused ? color : themeColorForeground }}>Home</Text>
|
|
||||||
),
|
|
||||||
drawerIcon: ({ size, color, focused }) => (
|
|
||||||
<Ionicons
|
|
||||||
name="home-outline"
|
|
||||||
size={size}
|
|
||||||
color={focused ? color : themeColorForeground}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Drawer.Screen
|
|
||||||
name="(tabs)"
|
|
||||||
options={{
|
|
||||||
headerTitle: "Tabs",
|
|
||||||
drawerLabel: ({ color, focused }) => (
|
|
||||||
<Text style={{ color: focused ? color : themeColorForeground }}>Tabs</Text>
|
|
||||||
),
|
|
||||||
drawerIcon: ({ size, color, focused }) => (
|
|
||||||
<MaterialIcons
|
|
||||||
name="border-bottom"
|
|
||||||
size={size}
|
|
||||||
color={focused ? color : themeColorForeground}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
headerRight: () => (
|
|
||||||
<Link href="/modal" asChild>
|
|
||||||
<Pressable className="mr-4">
|
|
||||||
<Ionicons name="add-outline" size={24} color={themeColorForeground} />
|
|
||||||
</Pressable>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DrawerLayout;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Card, Chip, useThemeColor } from "heroui-native";
|
|
||||||
import { Text, View, Pressable } from "react-native";
|
|
||||||
|
|
||||||
import { Container } from "@/components/container";
|
|
||||||
import { SignIn } from "@/components/sign-in";
|
|
||||||
import { SignUp } from "@/components/sign-up";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
|
||||||
const mutedColor = useThemeColor("muted");
|
|
||||||
const successColor = useThemeColor("success");
|
|
||||||
const dangerColor = useThemeColor("danger");
|
|
||||||
const foregroundColor = useThemeColor("foreground");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container className="p-6">
|
|
||||||
<View className="py-4 mb-6">
|
|
||||||
<Text className="text-4xl font-bold text-foreground mb-2">BETTER T STACK</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{session?.user ? (
|
|
||||||
<Card variant="secondary" className="mb-6 p-4">
|
|
||||||
<Text className="text-foreground text-base mb-2">
|
|
||||||
Welcome, <Text className="font-medium">{session.user.name}</Text>
|
|
||||||
</Text>
|
|
||||||
<Text className="text-muted text-sm mb-4">{session.user.email}</Text>
|
|
||||||
<Pressable
|
|
||||||
className="bg-danger py-3 px-4 rounded-lg self-start active:opacity-70"
|
|
||||||
onPress={() => {
|
|
||||||
authClient.signOut();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-foreground font-medium">Sign Out</Text>
|
|
||||||
</Pressable>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!session?.user && (
|
|
||||||
<>
|
|
||||||
<SignIn />
|
|
||||||
<SignUp />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,96 @@
|
|||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
import { Stack } from "expo-router";
|
import "@/src/i18n";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { HeroUINativeProvider } from "heroui-native";
|
import { HeroUINativeProvider } from "heroui-native";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Linking, Alert } from "react-native";
|
||||||
|
|
||||||
import { AppThemeProvider } from "@/contexts/app-theme-context";
|
import { AppThemeProvider } from "@/contexts/app-theme-context";
|
||||||
|
import { queryClient } from "@/src/lib/query-client";
|
||||||
|
import { authClient } from "@/src/lib/auth-client";
|
||||||
|
import { useAuthStore } from "@/src/stores/auth.store";
|
||||||
|
|
||||||
export const unstable_settings = {
|
if (__DEV__) {
|
||||||
initialRouteName: "(drawer)",
|
const originalConsoleError = console.error;
|
||||||
};
|
console.error = (...args: unknown[]) => {
|
||||||
|
if (typeof args[0] === "string" && args[0].includes("Maximum update depth")) return;
|
||||||
|
originalConsoleError(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function StackLayout() {
|
function DeepLinkHandler() {
|
||||||
return (
|
const router = useRouter();
|
||||||
<Stack screenOptions={{}}>
|
|
||||||
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
|
async function handleUrl(url: string) {
|
||||||
<Stack.Screen name="modal" options={{ title: "Modal", presentation: "modal" }} />
|
// Match haushaltsApp://invite?invitationId=xxx
|
||||||
</Stack>
|
const match = url.match(/haushaltsApp:\/\/invite\?invitationId=([^&]+)/i);
|
||||||
);
|
if (!match) return;
|
||||||
|
const invitationId = match[1]!;
|
||||||
|
|
||||||
|
const isLoggedIn = !!useAuthStore.getState().user;
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
// Store invitationId and redirect after login
|
||||||
|
useAuthStore.getState().setPendingInvitationId(invitationId);
|
||||||
|
router.replace("/(auth)/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authClient.organization.acceptInvitation({ invitationId });
|
||||||
|
if (result.error) throw new Error(result.error.message ?? "Fehler");
|
||||||
|
|
||||||
|
// Refresh households list
|
||||||
|
const { apiRequest } = await import("@/src/lib/api-client");
|
||||||
|
const householdsResponse = await apiRequest<{ households: { id: string; name: string; role: string }[] }>("/api/households");
|
||||||
|
useAuthStore.getState().setHouseholds(householdsResponse.households);
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
Alert.alert("Einladung angenommen", "Du bist jetzt Mitglied des Haushalts.");
|
||||||
|
router.replace("/(app)/haushalt");
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert("Fehler", (err as Error).message ?? "Einladung konnte nicht angenommen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle cold start URL
|
||||||
|
Linking.getInitialURL().then((url) => {
|
||||||
|
if (url) void handleUrl(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle foreground/background URL
|
||||||
|
const sub = Linking.addEventListener("url", ({ url }) => {
|
||||||
|
void handleUrl(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.remove();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<KeyboardProvider>
|
<AppThemeProvider>
|
||||||
<AppThemeProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<HeroUINativeProvider>
|
<KeyboardProvider>
|
||||||
<StackLayout />
|
<HeroUINativeProvider>
|
||||||
</HeroUINativeProvider>
|
<DeepLinkHandler />
|
||||||
</AppThemeProvider>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
</KeyboardProvider>
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="(auth)" />
|
||||||
|
<Stack.Screen name="(app)" />
|
||||||
|
<Stack.Screen name="+not-found" />
|
||||||
|
</Stack>
|
||||||
|
</HeroUINativeProvider>
|
||||||
|
</KeyboardProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppThemeProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
apps/native/app/index.tsx
Normal file
5
apps/native/app/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return <Redirect href="/(auth)/login" />;
|
||||||
|
}
|
||||||
BIN
apps/native/assets/adaptive-icon.png
Normal file
BIN
apps/native/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/native/assets/icon.png
Normal file
BIN
apps/native/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/native/assets/splash-icon.png
Normal file
BIN
apps/native/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1,55 +1,28 @@
|
|||||||
import React, { createContext, useCallback, useContext, useMemo } from "react";
|
import { createContext, useContext, useState, useEffect } from "react";
|
||||||
import { Uniwind, useUniwind } from "uniwind";
|
import { Appearance } from "react-native";
|
||||||
|
import { Uniwind } from "uniwind";
|
||||||
|
|
||||||
type ThemeName = "light" | "dark";
|
type AppThemeContextType = { themeName: string };
|
||||||
|
|
||||||
type AppThemeContextType = {
|
const AppThemeContext = createContext<AppThemeContextType>({ themeName: "light" });
|
||||||
currentTheme: string;
|
|
||||||
isLight: boolean;
|
|
||||||
isDark: boolean;
|
|
||||||
setTheme: (theme: ThemeName) => void;
|
|
||||||
toggleTheme: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AppThemeContext = createContext<AppThemeContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const AppThemeProvider = ({ children }: { children: React.ReactNode }) => {
|
export const AppThemeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { theme } = useUniwind();
|
const [themeName, setThemeName] = useState<string>(
|
||||||
|
() => Uniwind.currentTheme ?? "light",
|
||||||
const isLight = useMemo(() => {
|
|
||||||
return theme === "light";
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const isDark = useMemo(() => {
|
|
||||||
return theme === "dark";
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const setTheme = useCallback((newTheme: ThemeName) => {
|
|
||||||
Uniwind.setTheme(newTheme);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
|
||||||
Uniwind.setTheme(theme === "light" ? "dark" : "light");
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({
|
|
||||||
currentTheme: theme,
|
|
||||||
isLight,
|
|
||||||
isDark,
|
|
||||||
setTheme,
|
|
||||||
toggleTheme,
|
|
||||||
}),
|
|
||||||
[theme, isLight, isDark, setTheme, toggleTheme],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AppThemeContext.Provider value={value}>{children}</AppThemeContext.Provider>;
|
useEffect(() => {
|
||||||
|
const sub = Appearance.addChangeListener(({ colorScheme }) => {
|
||||||
|
setThemeName(colorScheme ?? "light");
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppThemeContext.Provider value={{ themeName }}>
|
||||||
|
{children}
|
||||||
|
</AppThemeContext.Provider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useAppTheme() {
|
export const useAppTheme = () => useContext(AppThemeContext);
|
||||||
const context = useContext(AppThemeContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useAppTheme must be used within AppThemeProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,32 +9,42 @@
|
|||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"prebuild": "expo prebuild",
|
"prebuild": "expo prebuild",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web",
|
||||||
|
"test": "bun test src/__tests__"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/expo": "catalog:",
|
"@better-auth/expo": "1.5.2",
|
||||||
"@expo/metro-runtime": "~55.0.6",
|
"@expo/metro-runtime": "~55.0.6",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "^5",
|
"@gorhom/bottom-sheet": "^5",
|
||||||
"@haushaltsApp/env": "workspace:*",
|
"@react-native-ml-kit/text-recognition": "^2.0.0",
|
||||||
"@react-navigation/drawer": "^7.3.9",
|
"@react-navigation/drawer": "^7.3.9",
|
||||||
"@react-navigation/elements": "^2.8.1",
|
"@react-navigation/elements": "^2.8.1",
|
||||||
"@tanstack/react-form": "catalog:",
|
"@tanstack/react-form": "^1.28.0",
|
||||||
"better-auth": "catalog:",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"dotenv": "catalog:",
|
"better-auth": "1.5.2",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
"expo": "^55.0.0",
|
"expo": "^55.0.0",
|
||||||
|
"expo-apple-authentication": "^55.0.8",
|
||||||
|
"expo-camera": "^55.0.10",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-constants": "~55.0.7",
|
||||||
|
"expo-file-system": "^55.0.11",
|
||||||
"expo-font": "~55.0.4",
|
"expo-font": "~55.0.4",
|
||||||
"expo-haptics": "~55.0.8",
|
"expo-haptics": "~55.0.8",
|
||||||
|
"expo-image-picker": "^55.0.13",
|
||||||
"expo-linking": "~55.0.7",
|
"expo-linking": "~55.0.7",
|
||||||
|
"expo-localization": "^55.0.8",
|
||||||
"expo-network": "~55.0.8",
|
"expo-network": "~55.0.8",
|
||||||
"expo-router": "~55.0.2",
|
"expo-router": "~55.0.2",
|
||||||
"expo-secure-store": "~55.0.8",
|
"expo-secure-store": "~55.0.8",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-status-bar": "~55.0.4",
|
||||||
|
"expo-system-ui": "^55.0.9",
|
||||||
"expo-web-browser": "~55.0.9",
|
"expo-web-browser": "~55.0.9",
|
||||||
"heroui-native": "^1.0.0-rc.3",
|
"heroui-native": "^1.0.0-rc.3",
|
||||||
|
"i18next": "^25.8.18",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-i18next": "^16.5.8",
|
||||||
"react-native": "0.83.2",
|
"react-native": "0.83.2",
|
||||||
"react-native-gesture-handler": "~2.30.0",
|
"react-native-gesture-handler": "~2.30.0",
|
||||||
"react-native-keyboard-controller": "1.20.7",
|
"react-native-keyboard-controller": "1.20.7",
|
||||||
@@ -46,12 +56,12 @@
|
|||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "^4.1.18",
|
||||||
"uniwind": "^1.4.0",
|
"uniwind": "^1.4.0",
|
||||||
"zod": "catalog:"
|
"zod": "^4.1.13",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@haushaltsApp/config": "workspace:*",
|
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
16
apps/native/src/__mocks__/expo-secure-store.ts
Normal file
16
apps/native/src/__mocks__/expo-secure-store.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Mock for expo-secure-store used in bun:test
|
||||||
|
// The real module requires React Native internals which aren't available in bun:test
|
||||||
|
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
export async function getItemAsync(key: string): Promise<string | null> {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setItemAsync(key: string, value: string): Promise<void> {
|
||||||
|
store.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteItemAsync(key: string): Promise<void> {
|
||||||
|
store.delete(key);
|
||||||
|
}
|
||||||
36
apps/native/src/__tests__/hooks/useTransactions.test.ts
Normal file
36
apps/native/src/__tests__/hooks/useTransactions.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// Mock the api-client — no actual fetch
|
||||||
|
mock.module("../../lib/api-client", () => ({
|
||||||
|
apiRequest: async (path: string) => {
|
||||||
|
if (path.includes("summary")) {
|
||||||
|
return { income: 1000, expense: 500, balance: 500 };
|
||||||
|
}
|
||||||
|
return { transactions: [] };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock expo-secure-store (pulled in transitively via auth-store)
|
||||||
|
mock.module("expo-secure-store", () => ({
|
||||||
|
getItemAsync: async () => null,
|
||||||
|
setItemAsync: async () => {},
|
||||||
|
deleteItemAsync: async () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("react-native", () => ({}));
|
||||||
|
mock.module("@haushaltsApp/env/native", () => ({
|
||||||
|
env: { EXPO_PUBLIC_SERVER_URL: "http://localhost:3000" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useTransactions query keys", () => {
|
||||||
|
it("summary query key is correct", () => {
|
||||||
|
const key = ["transactions", "summary"];
|
||||||
|
expect(key).toEqual(["transactions", "summary"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list query key with filters", () => {
|
||||||
|
const filters = { type: "expense" as const };
|
||||||
|
const key = ["transactions", filters];
|
||||||
|
expect(key[1]).toEqual(filters);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
apps/native/src/__tests__/stores/auth.store.test.ts
Normal file
87
apps/native/src/__tests__/stores/auth.store.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { create, type StoreApi, type UseBoundStore } from "zustand";
|
||||||
|
import { authStateCreator, type AuthState } from "../../stores/auth.store";
|
||||||
|
|
||||||
|
// Test the pure state logic via authStateCreator — no React Native imports needed.
|
||||||
|
// The persisted useAuthStore (with expo-secure-store) is tested via Expo Go.
|
||||||
|
|
||||||
|
let store: UseBoundStore<StoreApi<AuthState>>;
|
||||||
|
|
||||||
|
describe("authStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
store = create<AuthState>()(authStateCreator);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initial state is unauthenticated", () => {
|
||||||
|
const state = store.getState();
|
||||||
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
expect(state.user).toBeNull();
|
||||||
|
expect(state.activeHouseholdId).toBeNull();
|
||||||
|
expect(state.households).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setUser authenticates the user", () => {
|
||||||
|
const user = { id: "1", name: "Test User", email: "test@example.com" };
|
||||||
|
store.getState().setUser(user);
|
||||||
|
|
||||||
|
const state = store.getState();
|
||||||
|
expect(state.user).toEqual(user);
|
||||||
|
expect(state.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearAuth clears user, activeHouseholdId and households", () => {
|
||||||
|
store.setState({
|
||||||
|
user: { id: "1", name: "Test", email: "t@t.com" },
|
||||||
|
activeHouseholdId: "household-abc",
|
||||||
|
households: [{ id: "household-abc", name: "Test Household", role: "owner" }],
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.getState().clearAuth();
|
||||||
|
|
||||||
|
const state = store.getState();
|
||||||
|
expect(state.user).toBeNull();
|
||||||
|
expect(state.activeHouseholdId).toBeNull();
|
||||||
|
expect(state.households).toEqual([]);
|
||||||
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearSession resets user, activeHouseholdId, households and isAuthenticated", () => {
|
||||||
|
store.setState({
|
||||||
|
user: { id: "1", name: "Test", email: "t@t.com" },
|
||||||
|
activeHouseholdId: "household-abc",
|
||||||
|
households: [{ id: "household-abc", name: "Test Household", role: "owner" }],
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.getState().clearSession();
|
||||||
|
|
||||||
|
const state = store.getState();
|
||||||
|
expect(state.user).toBeNull();
|
||||||
|
expect(state.activeHouseholdId).toBeNull();
|
||||||
|
expect(state.households).toEqual([]);
|
||||||
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setActiveHousehold stores the id", () => {
|
||||||
|
store.getState().setActiveHousehold("household-123");
|
||||||
|
expect(store.getState().activeHouseholdId).toBe("household-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setHouseholds stores the list", () => {
|
||||||
|
const households = [
|
||||||
|
{ id: "hh-1", name: "Household One", role: "owner" },
|
||||||
|
{ id: "hh-2", name: "Household Two", role: "member" },
|
||||||
|
];
|
||||||
|
store.getState().setHouseholds(households);
|
||||||
|
expect(store.getState().households).toEqual(households);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearAuth resets activeHouseholdId and households", () => {
|
||||||
|
store.getState().setActiveHousehold("household-123");
|
||||||
|
store.getState().setHouseholds([{ id: "household-123", name: "My Home", role: "owner" }]);
|
||||||
|
store.getState().clearAuth();
|
||||||
|
expect(store.getState().activeHouseholdId).toBeNull();
|
||||||
|
expect(store.getState().households).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/native/src/components/features/PlaceholderScreen.tsx
Normal file
17
apps/native/src/components/features/PlaceholderScreen.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
type PlaceholderScreenProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlaceholderScreen({ title, description }: PlaceholderScreenProps) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text className="mb-2 text-2xl font-bold">{title}</Text>
|
||||||
|
{description && (
|
||||||
|
<Text className="text-center text-gray-500">{description}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useCreateCategory, type Category } from "@/src/hooks/useCategories";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// 30 emoji-like icon names from Ionicons — no external lib needed
|
||||||
|
const ICON_OPTIONS: Array<{ name: React.ComponentProps<typeof Ionicons>["name"]; label: string }> = [
|
||||||
|
{ name: "cart-outline", label: "Einkauf" },
|
||||||
|
{ name: "home-outline", label: "Haus" },
|
||||||
|
{ name: "car-outline", label: "Auto" },
|
||||||
|
{ name: "medkit-outline", label: "Gesundheit" },
|
||||||
|
{ name: "game-controller-outline", label: "Spiel" },
|
||||||
|
{ name: "happy-outline", label: "Kinder" },
|
||||||
|
{ name: "airplane-outline", label: "Urlaub" },
|
||||||
|
{ name: "briefcase-outline", label: "Arbeit" },
|
||||||
|
{ name: "cash-outline", label: "Geld" },
|
||||||
|
{ name: "restaurant-outline", label: "Essen" },
|
||||||
|
{ name: "fitness-outline", label: "Sport" },
|
||||||
|
{ name: "book-outline", label: "Bildung" },
|
||||||
|
{ name: "musical-notes-outline", label: "Musik" },
|
||||||
|
{ name: "phone-portrait-outline", label: "Handy" },
|
||||||
|
{ name: "wifi-outline", label: "Internet" },
|
||||||
|
{ name: "shirt-outline", label: "Kleidung" },
|
||||||
|
{ name: "paw-outline", label: "Tier" },
|
||||||
|
{ name: "gift-outline", label: "Geschenk" },
|
||||||
|
{ name: "construct-outline", label: "Reparatur" },
|
||||||
|
{ name: "cut-outline", label: "Friseur" },
|
||||||
|
{ name: "bus-outline", label: "Bus" },
|
||||||
|
{ name: "train-outline", label: "Bahn" },
|
||||||
|
{ name: "bicycle-outline", label: "Fahrrad" },
|
||||||
|
{ name: "cafe-outline", label: "Café" },
|
||||||
|
{ name: "beer-outline", label: "Bar" },
|
||||||
|
{ name: "tv-outline", label: "TV" },
|
||||||
|
{ name: "camera-outline", label: "Foto" },
|
||||||
|
{ name: "flower-outline", label: "Garten" },
|
||||||
|
{ name: "star-outline", label: "Sonstiges" },
|
||||||
|
{ name: "ellipsis-horizontal-circle-outline", label: "Allgemein" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#10b981", "#6366f1", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||||
|
"#ec4899", "#0ea5e9", "#6b7280", "#f97316", "#14b8a6",
|
||||||
|
"#84cc16", "#a855f7",
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
defaultType?: "income" | "expense";
|
||||||
|
onCreated?: (cat: Category) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddCategoryModal({ visible, onClose, defaultType = "expense", onCreated }: Props) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [selectedIcon, setSelectedIcon] = useState<React.ComponentProps<typeof Ionicons>["name"]>("star-outline");
|
||||||
|
const [selectedColor, setSelectedColor] = useState(COLORS[0]!);
|
||||||
|
const [type, setType] = useState<"income" | "expense">(defaultType);
|
||||||
|
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useCreateCategory();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
create(
|
||||||
|
{ name: trimmed, icon: selectedIcon, color: selectedColor, type },
|
||||||
|
{
|
||||||
|
onSuccess: (cat) => {
|
||||||
|
onCreated?.(cat);
|
||||||
|
resetAndClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAndClose() {
|
||||||
|
setName("");
|
||||||
|
setSelectedIcon("star-outline");
|
||||||
|
setSelectedColor(COLORS[0]!);
|
||||||
|
setType(defaultType);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<ModalHeader
|
||||||
|
title={t('categories.addTitle')}
|
||||||
|
onClose={resetAndClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t('common.create')}
|
||||||
|
saveDisabled={!name.trim()}
|
||||||
|
saveLoading={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
|
{/* Type Toggle */}
|
||||||
|
<View className="flex-row p-1 bg-gray-100 rounded-xl mb-5">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setType("expense")}
|
||||||
|
className={`flex-1 py-2 rounded-lg items-center ${type === "expense" ? "bg-white shadow-sm" : ""}`}
|
||||||
|
>
|
||||||
|
<Text className={`font-medium ${type === "expense" ? "text-red-600" : "text-gray-500"}`}>
|
||||||
|
{t('categories.expenseType')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setType("income")}
|
||||||
|
className={`flex-1 py-2 rounded-lg items-center ${type === "income" ? "bg-white shadow-sm" : ""}`}
|
||||||
|
>
|
||||||
|
<Text className={`font-medium ${type === "income" ? "text-green-600" : "text-gray-500"}`}>
|
||||||
|
{t('categories.incomeType')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">{t('categories.nameLabel')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-5"
|
||||||
|
placeholder={t('categories.namePlaceholder')}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<View className="flex-row items-center gap-3 mb-5 p-3 bg-gray-50 rounded-xl">
|
||||||
|
<View
|
||||||
|
className="w-10 h-10 rounded-full items-center justify-center"
|
||||||
|
style={{ backgroundColor: selectedColor }}
|
||||||
|
>
|
||||||
|
<Ionicons name={selectedIcon} size={20} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text className="text-base font-medium text-gray-800">
|
||||||
|
{name.trim() || t('common.preview')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-3">{t('categories.colorLabel')}</Text>
|
||||||
|
<View className="flex-row flex-wrap gap-2 mb-5">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<Pressable
|
||||||
|
key={c}
|
||||||
|
onPress={() => setSelectedColor(c)}
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: c,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: selectedColor === c ? 3 : 0,
|
||||||
|
borderColor: "#fff",
|
||||||
|
shadowColor: selectedColor === c ? c : "transparent",
|
||||||
|
shadowOpacity: selectedColor === c ? 0.6 : 0,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: selectedColor === c ? 4 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedColor === c && <Ionicons name="checkmark" size={16} color="#fff" />}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Icon Picker — select row */}
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-2">{t('categories.iconLabel')}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setIconPickerOpen((v) => !v)}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#f3f4f6",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginBottom: iconPickerOpen ? 8 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: selectedColor,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginRight: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={selectedIcon} size={17} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ flex: 1, fontSize: 14, color: "#374151" }}>
|
||||||
|
{ICON_OPTIONS.find((o) => o.name === selectedIcon)?.label ?? t('categories.selectIcon')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={iconPickerOpen ? "chevron-up" : "chevron-down"}
|
||||||
|
size={16}
|
||||||
|
color="#9ca3af"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Dropdown grid */}
|
||||||
|
{iconPickerOpen && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#e5e7eb",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 8,
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ICON_OPTIONS.map((opt) => {
|
||||||
|
const active = selectedIcon === opt.name;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={opt.name}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedIcon(opt.name);
|
||||||
|
setIconPickerOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: active ? selectedColor : "#f3f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={opt.name} size={20} color={active ? "#fff" : "#6b7280"} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
apps/native/src/components/features/debts/AddDebtModal.tsx
Normal file
187
apps/native/src/components/features/debts/AddDebtModal.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useCreateDebt } from "@/src/hooks/useDebts";
|
||||||
|
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
|
||||||
|
import { useAuthStore } from "@/src/stores/auth.store";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Numpad } from "@/src/components/ui/Numpad";
|
||||||
|
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddDebtModal({ visible, onClose }: Props) {
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [amountStr, setAmountStr] = useState("0");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
// creditor: internal member OR free text
|
||||||
|
const [creditorUserId, setCreditorUserId] = useState<string | null>(null);
|
||||||
|
const [creditorText, setCreditorText] = useState("");
|
||||||
|
const [showMemberPicker, setShowMemberPicker] = useState(false);
|
||||||
|
|
||||||
|
const { mutate: createDebt, isPending } = useCreateDebt();
|
||||||
|
const { data: membersData } = useHouseholdMembers();
|
||||||
|
const myUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Only other members (not myself)
|
||||||
|
const otherMembers = (membersData?.members ?? []).filter((m) => m.userId !== myUserId);
|
||||||
|
|
||||||
|
const selectedMember = otherMembers.find((m) => m.userId === creditorUserId) ?? null;
|
||||||
|
|
||||||
|
function handleNumpad(key: string) {
|
||||||
|
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const amount = parseAmountStr(amountStr);
|
||||||
|
if (!label.trim() || !amount || amount <= 0) return;
|
||||||
|
createDebt(
|
||||||
|
{
|
||||||
|
label: label.trim(),
|
||||||
|
creditorUserId: creditorUserId ?? undefined,
|
||||||
|
creditor: !creditorUserId && creditorText.trim() ? creditorText.trim() : undefined,
|
||||||
|
totalAmount: amount,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: resetAndClose },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAndClose() {
|
||||||
|
setLabel("");
|
||||||
|
setAmountStr("0");
|
||||||
|
setNotes("");
|
||||||
|
setCreditorUserId(null);
|
||||||
|
setCreditorText("");
|
||||||
|
setShowMemberPicker(false);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<ModalHeader
|
||||||
|
title={t('debts.addTitle')}
|
||||||
|
onClose={resetAndClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t('common.save')}
|
||||||
|
saveDisabled={!canSave}
|
||||||
|
saveLoading={isPending}
|
||||||
|
saveColor="#7c3aed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingBottom: 24 }}>
|
||||||
|
{/* Amount display */}
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||||
|
<Text className="text-sm text-gray-400 mt-1">{t('debts.totalAmount')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<View className="px-4 gap-3 mb-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.labelRequired')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('debts.labelPlaceholder')}
|
||||||
|
value={label}
|
||||||
|
onChangeText={setLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Creditor picker */}
|
||||||
|
<View>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.iOweMoneyTo')}</Text>
|
||||||
|
|
||||||
|
{/* Member select row */}
|
||||||
|
{otherMembers.length > 0 && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowMemberPicker((v) => !v)}
|
||||||
|
className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 mb-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="people-outline" size={16} color="#7c3aed" style={{ marginRight: 8 }} />
|
||||||
|
<Text className="flex-1 text-base" style={{ color: selectedMember ? "#111827" : "#9ca3af" }}>
|
||||||
|
{selectedMember ? selectedMember.name : t('debts.selectMember')}
|
||||||
|
</Text>
|
||||||
|
{selectedMember ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => { e.stopPropagation(); setCreditorUserId(null); }}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Ionicons name={showMemberPicker ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member dropdown */}
|
||||||
|
{showMemberPicker && (
|
||||||
|
<View className="bg-white border border-gray-200 rounded-xl mb-2 overflow-hidden">
|
||||||
|
{otherMembers.map((m) => (
|
||||||
|
<Pressable
|
||||||
|
key={m.userId}
|
||||||
|
onPress={() => { setCreditorUserId(m.userId); setCreditorText(""); setShowMemberPicker(false); }}
|
||||||
|
className="flex-row items-center px-4 py-3 active:bg-gray-50"
|
||||||
|
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<View className="w-7 h-7 rounded-full bg-purple-100 items-center justify-center mr-3">
|
||||||
|
<Text className="text-xs font-bold text-purple-700">
|
||||||
|
{m.name.charAt(0).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm text-gray-800">{m.name}</Text>
|
||||||
|
{creditorUserId === m.userId && (
|
||||||
|
<Ionicons name="checkmark" size={16} color="#7c3aed" style={{ marginLeft: "auto" }} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Free-text fallback (only when no member selected) */}
|
||||||
|
{!creditorUserId && (
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('debts.orEnterName')}
|
||||||
|
value={creditorText}
|
||||||
|
onChangeText={setCreditorText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.noteOptional')}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('debts.notePlaceholder')}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Numpad */}
|
||||||
|
<Numpad onKeyPress={handleNumpad} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useCreateDebtPayment, type Debt } from "@/src/hooks/useDebts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Numpad } from "@/src/components/ui/Numpad";
|
||||||
|
import { todayIso } from "@/src/utils/date";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visible: boolean;
|
||||||
|
debt: Debt;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddDebtPaymentModal({ visible, debt, onClose }: Props) {
|
||||||
|
const [amountStr, setAmountStr] = useState("0");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
const { mutate: createPayment, isPending } = useCreateDebtPayment();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function handleNumpad(key: string) {
|
||||||
|
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const amount = parseAmountStr(amountStr);
|
||||||
|
if (!amount || amount <= 0) return;
|
||||||
|
createPayment(
|
||||||
|
{
|
||||||
|
debtId: debt.id,
|
||||||
|
amount,
|
||||||
|
date: todayIso(),
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
resetAndClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAndClose() {
|
||||||
|
setAmountStr("0");
|
||||||
|
setNote("");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedAmount = parseAmountStr(amountStr);
|
||||||
|
const canSave = parsedAmount > 0;
|
||||||
|
const isOverpaying = parsedAmount > debt.remainingAmount + 0.005;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<ModalHeader
|
||||||
|
title={t('debts.payRate')}
|
||||||
|
onClose={resetAndClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t('common.book')}
|
||||||
|
saveDisabled={!canSave}
|
||||||
|
saveLoading={isPending}
|
||||||
|
saveColor="#7c3aed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Debt info */}
|
||||||
|
<View className="mx-4 mt-4 p-4 bg-purple-50 rounded-2xl mb-2">
|
||||||
|
<Text className="text-sm font-semibold text-purple-900">{debt.label}</Text>
|
||||||
|
{debt.creditor && (
|
||||||
|
<Text className="text-xs text-purple-500 mt-0.5">{debt.creditor}</Text>
|
||||||
|
)}
|
||||||
|
<Text className="text-xs text-purple-600 mt-2">
|
||||||
|
{t('debts.remaining', { amount: formatEur(debt.remainingAmount, false) })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Amount display */}
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||||
|
{isOverpaying && (
|
||||||
|
<Text className="text-xs text-orange-500 mt-1">
|
||||||
|
{t('debts.overpayingWarning')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Note field */}
|
||||||
|
<View className="px-4 mb-4">
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('debts.noteOptional')}
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Numpad */}
|
||||||
|
<Numpad onKeyPress={handleNumpad} />
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
apps/native/src/components/features/debts/ClaimsSection.tsx
Normal file
148
apps/native/src/components/features/debts/ClaimsSection.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
|
import { useClaims, type Debt } from "@/src/hooks/useDebts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
|
||||||
|
function ClaimCard({ debt }: { debt: Debt }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const isClosed = debt.closedAt !== null;
|
||||||
|
const accentColor = isClosed ? "#10b981" : "#7c3aed";
|
||||||
|
const pct = Math.round(debt.progressPercent);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// creditorUserName here = the debtor's name (person who owes me money)
|
||||||
|
const debtorName = debt.creditorUserName ?? t('debts.unknown');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="bg-white rounded-2xl mx-4 mb-2 overflow-hidden"
|
||||||
|
style={{ borderWidth: 1, borderColor: isClosed ? "#d1fae5" : "#e0e7ff" }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setExpanded((v) => !v)}
|
||||||
|
className="flex-row items-center px-4 py-3 active:opacity-80"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="w-9 h-9 rounded-full items-center justify-center mr-3"
|
||||||
|
style={{ backgroundColor: isClosed ? "#d1fae5" : "#e0e7ff" }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isClosed ? "checkmark-circle" : "cash-outline"}
|
||||||
|
size={18}
|
||||||
|
color={accentColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 mr-2">
|
||||||
|
<View className="flex-row items-center justify-between mb-1.5">
|
||||||
|
<Text className="text-sm font-semibold text-gray-900" numberOfLines={1}>
|
||||||
|
{debt.label}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs font-bold ml-2" style={{ color: accentColor }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<View
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ width: `${debt.progressPercent}%`, backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs text-gray-400 mt-1">
|
||||||
|
{t('debts.fromDebtor', { name: debtorName, amount: formatEur(debt.remainingAmount, false) })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? "chevron-up" : "chevron-down"}
|
||||||
|
size={14}
|
||||||
|
color="#9ca3af"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<View style={{ borderTopWidth: 1, borderTopColor: "#f3f4f6" }}>
|
||||||
|
<View className="flex-row px-4 py-3 justify-between">
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs text-gray-400">{t('debts.received')}</Text>
|
||||||
|
<Text className="text-sm font-semibold" style={{ color: accentColor }}>
|
||||||
|
{formatEur(debt.paidAmount, false)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-center">
|
||||||
|
<Text className="text-xs text-gray-400">{t('debts.total')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-gray-700">
|
||||||
|
{formatEur(debt.totalAmount, false)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-end">
|
||||||
|
<Text className="text-xs text-gray-400">{t('debts.pendingLabel')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-gray-900">
|
||||||
|
{formatEur(debt.remainingAmount, false)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{debt.notes && (
|
||||||
|
<Text className="text-xs text-gray-400 px-4 pb-3">{debt.notes}</Text>
|
||||||
|
)}
|
||||||
|
{isClosed && (
|
||||||
|
<View className="mx-4 mb-3 px-3 py-2 rounded-xl" style={{ backgroundColor: "#d1fae5" }}>
|
||||||
|
<Text className="text-xs font-medium text-center" style={{ color: "#059669" }}>
|
||||||
|
{t('debts.fullyRepaid')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClaimsSection() {
|
||||||
|
const { data: claims = [], isLoading } = useClaims();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showClosed, setShowClosed] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="py-4 items-center">
|
||||||
|
<ActivityIndicator size="small" color="#7c3aed" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claims.length === 0) return null;
|
||||||
|
|
||||||
|
const open = claims.filter((d) => d.closedAt === null);
|
||||||
|
const closed = claims.filter((d) => d.closedAt !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mb-2">
|
||||||
|
<View className="flex-row items-center px-4 py-3">
|
||||||
|
<Ionicons name="cash-outline" size={18} color="#7c3aed" style={{ marginRight: 8 }} />
|
||||||
|
<Text className="text-sm font-semibold text-gray-800">{t('debts.claims')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{open.map((debt) => (
|
||||||
|
<ClaimCard key={debt.id} debt={debt} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{closed.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowClosed((v) => !v)}
|
||||||
|
className="flex-row items-center gap-1 mx-4 mb-2"
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{t(showClosed ? 'debts.toggleClosed_hide' : 'debts.toggleClosed_show', { count: closed.length, plural: closed.length === 1 ? '' : 'r' })}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name={showClosed ? "chevron-up" : "chevron-down"} size={12} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
{showClosed && closed.map((debt) => <ClaimCard key={debt.id} debt={debt} />)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
apps/native/src/components/features/debts/DebtCard.tsx
Normal file
132
apps/native/src/components/features/debts/DebtCard.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import type { Debt } from "@/src/hooks/useDebts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
debt: Debt;
|
||||||
|
onAddPayment: (debt: Debt) => void;
|
||||||
|
onDelete: (debt: Debt) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DebtCard({ debt, onAddPayment, onDelete }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const isClosed = debt.closedAt !== null;
|
||||||
|
const accentColor = isClosed ? "#10b981" : "#7c3aed";
|
||||||
|
const pct = Math.round(debt.progressPercent);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="bg-white rounded-2xl mx-4 mb-2 overflow-hidden"
|
||||||
|
style={{ borderWidth: 1, borderColor: isClosed ? "#d1fae5" : "#ede9fe" }}
|
||||||
|
>
|
||||||
|
{/* ── Collapsed row (always visible) ── */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setExpanded((v) => !v)}
|
||||||
|
className="flex-row items-center px-4 py-3 active:opacity-80"
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<View
|
||||||
|
className="w-9 h-9 rounded-full items-center justify-center mr-3"
|
||||||
|
style={{ backgroundColor: isClosed ? "#d1fae5" : "#ede9fe" }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isClosed ? "checkmark-circle" : "card-outline"}
|
||||||
|
size={18}
|
||||||
|
color={accentColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Label + progress bar */}
|
||||||
|
<View className="flex-1 mr-2">
|
||||||
|
<View className="flex-row items-center justify-between mb-1.5">
|
||||||
|
<Text className="text-sm font-semibold text-gray-900" numberOfLines={1}>
|
||||||
|
{debt.label}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs font-bold ml-2" style={{ color: accentColor }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<View
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ width: `${debt.progressPercent}%`, backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{debt.creditor ? (
|
||||||
|
<Text className="text-xs text-gray-400 mt-1">{debt.creditor}</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="text-xs text-gray-400 mt-1">
|
||||||
|
{t('debts.remainingLabel', { amount: formatEur(debt.remainingAmount) })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? "chevron-up" : "chevron-down"}
|
||||||
|
size={14}
|
||||||
|
color="#9ca3af"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* ── Expanded content ── */}
|
||||||
|
{expanded && (
|
||||||
|
<View style={{ borderTopWidth: 1, borderTopColor: "#f3f4f6" }}>
|
||||||
|
{/* Amounts row */}
|
||||||
|
<View className="flex-row px-4 py-3 justify-between">
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs text-gray-400">{t('debts.paid')}</Text>
|
||||||
|
<Text className="text-sm font-semibold" style={{ color: accentColor }}>
|
||||||
|
{formatEur(debt.paidAmount, false)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-center">
|
||||||
|
<Text className="text-xs text-gray-400">{t('debts.total')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-gray-700">
|
||||||
|
{formatEur(debt.totalAmount, false)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-end">
|
||||||
|
<Text className="text-xs text-gray-400">{t('debts.openAmount')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-gray-900">
|
||||||
|
{formatEur(debt.remainingAmount, false)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{debt.notes && (
|
||||||
|
<Text className="text-xs text-gray-400 px-4 pb-2">{debt.notes}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
<View className="flex-row gap-2 px-4 pb-4">
|
||||||
|
{!isClosed && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onAddPayment(debt)}
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
className="flex-1 py-2.5 rounded-xl items-center active:opacity-80"
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-white">+ {t('debts.payRate')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
{!isClosed && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onDelete(debt)}
|
||||||
|
className="w-11 h-11 rounded-xl items-center justify-center"
|
||||||
|
style={{ backgroundColor: "#fef2f2" }}
|
||||||
|
hitSlop={4}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={16} color="#ef4444" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/native/src/components/features/debts/DebtsSection.tsx
Normal file
113
apps/native/src/components/features/debts/DebtsSection.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
|
import { useDebts, useDeleteDebt, type Debt } from "@/src/hooks/useDebts";
|
||||||
|
import { DebtCard } from "./DebtCard";
|
||||||
|
import { AddDebtModal } from "./AddDebtModal";
|
||||||
|
import { AddDebtPaymentModal } from "./AddDebtPaymentModal";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type ModalState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "addDebt" }
|
||||||
|
| { kind: "addPayment"; debt: Debt };
|
||||||
|
|
||||||
|
export function DebtsSection() {
|
||||||
|
const { data: debts = [], isLoading } = useDebts();
|
||||||
|
const { mutate: deleteDebt } = useDeleteDebt();
|
||||||
|
const [modal, setModal] = useState<ModalState>({ kind: "idle" });
|
||||||
|
const [showClosed, setShowClosed] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const openDebts = debts.filter((d) => d.closedAt === null);
|
||||||
|
const closedDebts = debts.filter((d) => d.closedAt !== null);
|
||||||
|
|
||||||
|
function handleDelete(debt: Debt) {
|
||||||
|
deleteDebt(debt.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className="mb-2">
|
||||||
|
{/* Section header */}
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-3">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Ionicons name="card-outline" size={18} color="#7c3aed" />
|
||||||
|
<Text className="text-sm font-semibold text-gray-800">{t('debts.title')}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setModal({ kind: "addDebt" })}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: "#ede9fe" }}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={14} color="#7c3aed" />
|
||||||
|
<Text className="text-xs font-semibold" style={{ color: "#7c3aed" }}>{t('common.new')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="py-6 items-center">
|
||||||
|
<ActivityIndicator size="small" color="#7c3aed" />
|
||||||
|
</View>
|
||||||
|
) : openDebts.length === 0 && closedDebts.length === 0 ? (
|
||||||
|
<View className="mx-4 mb-3 p-4 bg-gray-50 rounded-2xl items-center">
|
||||||
|
<Text className="text-sm text-gray-400 text-center">
|
||||||
|
{t('debts.noDebtsEntered')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{openDebts.map((debt) => (
|
||||||
|
<DebtCard
|
||||||
|
key={debt.id}
|
||||||
|
debt={debt}
|
||||||
|
onAddPayment={(d) => setModal({ kind: "addPayment", debt: d })}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{closedDebts.length > 0 && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowClosed((v) => !v)}
|
||||||
|
className="flex-row items-center gap-1 mx-4 mb-2"
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{t(showClosed ? 'debts.toggleClosed_hide' : 'debts.toggleClosed_show', { count: closedDebts.length, plural: closedDebts.length === 1 ? '' : 'r' })}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={showClosed ? "chevron-up" : "chevron-down"}
|
||||||
|
size={12}
|
||||||
|
color="#9ca3af"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showClosed &&
|
||||||
|
closedDebts.map((debt) => (
|
||||||
|
<DebtCard
|
||||||
|
key={debt.id}
|
||||||
|
debt={debt}
|
||||||
|
onAddPayment={() => {}}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Modals — only one open at a time */}
|
||||||
|
<AddDebtModal
|
||||||
|
visible={modal.kind === "addDebt"}
|
||||||
|
onClose={() => setModal({ kind: "idle" })}
|
||||||
|
/>
|
||||||
|
{modal.kind === "addPayment" && (
|
||||||
|
<AddDebtPaymentModal
|
||||||
|
visible
|
||||||
|
debt={modal.debt}
|
||||||
|
onClose={() => setModal({ kind: "idle" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { TAB_COLORS } from "@/src/constants/colors";
|
||||||
|
import { useMonthBalance, useCarryOver } from "@/src/hooks/useTransactions";
|
||||||
|
import { currentMonthStr, addMonths, monthLabel } from "@/src/utils/date";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
month: string; // "YYYY-MM" — the displayed (past) month
|
||||||
|
scope: "household" | "private" | "child";
|
||||||
|
childId?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CarryOverBanner({ month, scope, childId, accentColor = TAB_COLORS.household }: Props) {
|
||||||
|
const isCurrent = month >= currentMonthStr();
|
||||||
|
|
||||||
|
// Don't show for current or future months
|
||||||
|
if (isCurrent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarryOverBannerInner
|
||||||
|
month={month}
|
||||||
|
scope={scope}
|
||||||
|
childId={childId}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarryOverBannerInner({
|
||||||
|
month,
|
||||||
|
scope,
|
||||||
|
childId,
|
||||||
|
accentColor,
|
||||||
|
}: Required<Pick<Props, "month" | "scope" | "accentColor">> & { childId?: string }) {
|
||||||
|
const { data: balanceData } = useMonthBalance(scope, month, childId);
|
||||||
|
const { mutate: carryOver, isPending } = useCarryOver();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const balance = balanceData?.balance ?? 0;
|
||||||
|
|
||||||
|
// No banner if balance is ~zero
|
||||||
|
if (Math.abs(balance) < 0.01) return null;
|
||||||
|
|
||||||
|
const toMonth = addMonths(month, 1);
|
||||||
|
const toMonthLabel = monthLabel(toMonth);
|
||||||
|
const balanceLabel = balance > 0 ? `+${formatEur(balance)}` : `-${formatEur(balance)}`;
|
||||||
|
const isPositive = balance > 0;
|
||||||
|
|
||||||
|
function handleCarryOver() {
|
||||||
|
Alert.alert(
|
||||||
|
t('carryOver.title'),
|
||||||
|
t('carryOver.confirmMessage', {
|
||||||
|
balance: balanceLabel,
|
||||||
|
type: isPositive ? t('carryOver.expense') : t('carryOver.income'),
|
||||||
|
month: toMonthLabel,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t('carryOver.transfer'),
|
||||||
|
onPress: () => {
|
||||||
|
carryOver(
|
||||||
|
{ fromMonth: month, toMonth, scope, childId },
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
Alert.alert(t('common.notice'), err.message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mx-4 my-3 rounded-2xl p-4"
|
||||||
|
style={{ backgroundColor: "#f5f3ff", borderWidth: 1, borderColor: "#ddd6fe" }}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<Ionicons name="return-down-forward-outline" size={24} color="#6366f1" />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-xs text-gray-400 mb-0.5">{t('carryOver.openBalance', { month: monthLabel(month) })}</Text>
|
||||||
|
<Text className="text-xl font-bold" style={{ color: "#6366f1" }}>
|
||||||
|
{balanceLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCarryOver}
|
||||||
|
disabled={isPending}
|
||||||
|
className="mt-3 py-2 rounded-xl items-center active:opacity-70"
|
||||||
|
style={{ backgroundColor: "#6366f1" }}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-white">
|
||||||
|
{isPending ? t('carryOver.transferring') : t('carryOver.transferButton', { month: toMonthLabel })}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useUpdateTransaction } from "@/src/hooks/useTransactions";
|
||||||
|
import { useCategories, type Category } from "@/src/hooks/useCategories";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Numpad } from "@/src/components/ui/Numpad";
|
||||||
|
import { formatDateDisplay } from "@/src/utils/format";
|
||||||
|
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n from "@/src/i18n";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
transaction: TransactionWithCategory;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function amountToDisplay(amount: string): string {
|
||||||
|
return parseFloat(amount).toFixed(2).replace(".", ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditTransactionModal({ transaction, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [amountStr, setAmountStr] = useState(amountToDisplay(transaction.amount));
|
||||||
|
const [description, setDescription] = useState(transaction.description ?? "");
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
|
||||||
|
null, // will be set via category lookup
|
||||||
|
);
|
||||||
|
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||||
|
|
||||||
|
const { data: categories = [] } = useCategories();
|
||||||
|
const { mutate: updateTransaction, isPending } = useUpdateTransaction();
|
||||||
|
|
||||||
|
// Resolve initial category from transaction's categoryName
|
||||||
|
const [resolvedInitial, setResolvedInitial] = useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!resolvedInitial && categories.length > 0) {
|
||||||
|
const match = categories.find((c) => c.name === transaction.categoryName);
|
||||||
|
setSelectedCategoryId(match?.id ?? null);
|
||||||
|
setResolvedInitial(true);
|
||||||
|
}
|
||||||
|
}, [categories, resolvedInitial, transaction.categoryName]);
|
||||||
|
|
||||||
|
const filteredCategories = categories.filter((c) => c.type === transaction.type);
|
||||||
|
const selectedCategory = categories.find((c) => c.id === selectedCategoryId) ?? null;
|
||||||
|
|
||||||
|
function handleNumpad(key: string) {
|
||||||
|
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const amount = parseAmountStr(amountStr);
|
||||||
|
if (!amount || amount <= 0) return;
|
||||||
|
updateTransaction(
|
||||||
|
{
|
||||||
|
id: transaction.id,
|
||||||
|
amount,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
categoryId: selectedCategoryId ?? undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: onClose },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = parseAmountStr(amountStr) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<ModalHeader
|
||||||
|
title={t('transaction.editTitle')}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel={t('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={t('common.save')}
|
||||||
|
saveDisabled={!canSave}
|
||||||
|
saveLoading={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* isFixed warning */}
|
||||||
|
{transaction.isFixed && (
|
||||||
|
<View className="mx-4 mt-3 px-3 py-2 bg-amber-50 rounded-xl flex-row items-start gap-2">
|
||||||
|
<Text className="text-sm">⚠️</Text>
|
||||||
|
<Text className="text-xs text-amber-700 flex-1">
|
||||||
|
Das ist eine Fixkostenbuchung. Änderungen gelten nur für diesen Monat. Um den Betrag dauerhaft zu ändern, gehe zu Einstellungen → Fixkosten.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Text
|
||||||
|
className="text-5xl font-bold"
|
||||||
|
style={{ color: transaction.type === "income" ? "#16a34a" : "#111827" }}
|
||||||
|
>
|
||||||
|
€ {amountStr}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-400 mt-1">
|
||||||
|
{transaction.type === "income" ? t('transaction.income') : t('transaction.expense')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Category Select */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowCategoryPicker((v) => !v)}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row", alignItems: "center",
|
||||||
|
marginHorizontal: 16, marginBottom: 4,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 11,
|
||||||
|
backgroundColor: "#f3f4f6", borderRadius: 12,
|
||||||
|
borderWidth: selectedCategory ? 1.5 : 0,
|
||||||
|
borderColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "#e5e7eb", alignItems: "center", justifyContent: "center", marginRight: 10 }}>
|
||||||
|
<Ionicons
|
||||||
|
name={(selectedCategory?.icon ?? "pricetag-outline") as React.ComponentProps<typeof Ionicons>["name"]}
|
||||||
|
size={14}
|
||||||
|
color={selectedCategory ? "#fff" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={{ flex: 1, fontSize: 14, color: selectedCategory ? "#111827" : "#9ca3af" }}>
|
||||||
|
{selectedCategory ? selectedCategory.name : t('transaction.selectCategory')}
|
||||||
|
</Text>
|
||||||
|
{selectedCategory ? (
|
||||||
|
<Pressable onPress={(e) => { e.stopPropagation(); setSelectedCategoryId(null); }} hitSlop={8}>
|
||||||
|
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={16} color="#9ca3af" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Inline Category Picker */}
|
||||||
|
{showCategoryPicker && (
|
||||||
|
<View style={{ marginHorizontal: 16, marginBottom: 4, borderWidth: 1, borderColor: "#e5e7eb", borderRadius: 12, backgroundColor: "#fff", maxHeight: 220 }}>
|
||||||
|
<ScrollView bounces={false} keyboardShouldPersistTaps="handled">
|
||||||
|
{filteredCategories.map((cat) => {
|
||||||
|
const active = cat.id === selectedCategoryId;
|
||||||
|
const color = cat.color ?? "#6b7280";
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={cat.id}
|
||||||
|
onPress={() => { setSelectedCategoryId(active ? null : cat.id); setShowCategoryPicker(false); }}
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, backgroundColor: active ? `${color}12` : "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: color, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||||
|
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={16} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ flex: 1, fontSize: 14, fontWeight: "500", color: active ? color : "#111827" }}>{cat.name}</Text>
|
||||||
|
{active && <Ionicons name="checkmark-circle" size={18} color={color} />}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View className="px-4 mt-2 mb-3">
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||||
|
placeholder={t('transaction.descriptionOptional')}
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Date (readonly display) */}
|
||||||
|
<View className="px-4 mb-4 flex-row items-center" style={{ gap: 10 }}>
|
||||||
|
<Ionicons name="calendar-outline" size={20} color="#6b7280" />
|
||||||
|
<Text className="text-base text-gray-500">{formatDateDisplay(transaction.date, i18n.language, t('common.today'))}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Numpad */}
|
||||||
|
<Numpad onKeyPress={handleNumpad} />
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ActivityIndicator, Text, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatEur } from "@/src/utils/format";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
income: number | undefined;
|
||||||
|
expense: number | undefined;
|
||||||
|
balance: number | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
accentColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MonthSummaryHeader({ income, expense, balance, isLoading, accentColor }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="bg-white mx-4 mt-3 rounded-2xl p-4 items-center" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||||
|
<ActivityIndicator size="small" color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const balancePositive = (balance ?? 0) >= 0;
|
||||||
|
const balanceColor = accentColor ?? (balancePositive ? "#16a34a" : "#dc2626");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="bg-white mx-4 mt-3 mb-1 rounded-2xl px-4 py-3 flex-row"
|
||||||
|
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-xs text-gray-400 mb-1">{t('household.income')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-green-600">
|
||||||
|
{income !== undefined ? formatEur(income) : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="w-px bg-gray-100 mx-1" />
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-xs text-gray-400 mb-1">{t('household.expenses')}</Text>
|
||||||
|
<Text className="text-sm font-semibold text-red-500">
|
||||||
|
{expense !== undefined ? formatEur(expense) : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="w-px bg-gray-100 mx-1" />
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-xs text-gray-400 mb-1">{t('household.balance')}</Text>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: balanceColor }}
|
||||||
|
>
|
||||||
|
{balance !== undefined ? formatEur(balance) : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useCreateTransaction } from "@/src/hooks/useTransactions";
|
||||||
|
import { useCategories, type Category } from "@/src/hooks/useCategories";
|
||||||
|
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||||
|
import { Numpad } from "@/src/components/ui/Numpad";
|
||||||
|
import { todayIso } from "@/src/utils/date";
|
||||||
|
import { formatDateDisplay } from "@/src/utils/format";
|
||||||
|
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n from "@/src/i18n";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRequestAddCategory: (type: "expense" | "income") => void;
|
||||||
|
newCategory?: Category | null;
|
||||||
|
defaultScope?: "household" | "private" | "child";
|
||||||
|
defaultChildId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuickAddModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onRequestAddCategory,
|
||||||
|
newCategory,
|
||||||
|
defaultScope = "household",
|
||||||
|
defaultChildId,
|
||||||
|
}: Props) {
|
||||||
|
const { t: tFn } = useTranslation();
|
||||||
|
const [type, setType] = useState<"expense" | "income">("expense");
|
||||||
|
const [amountStr, setAmountStr] = useState("0");
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isFixed, setIsFixed] = useState(false);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>(todayIso());
|
||||||
|
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||||
|
|
||||||
|
const { data: categories = [] } = useCategories();
|
||||||
|
const filteredCategories = categories.filter((c) => c.type === type);
|
||||||
|
const selectedCategory = categories.find((c) => c.id === selectedCategoryId) ?? null;
|
||||||
|
|
||||||
|
// Auto-select newly created category when parent passes it in
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (newCategory) {
|
||||||
|
setSelectedCategoryId(newCategory.id);
|
||||||
|
setType(newCategory.type);
|
||||||
|
}
|
||||||
|
}, [newCategory]);
|
||||||
|
|
||||||
|
const { mutate: createTransaction, isPending } = useCreateTransaction();
|
||||||
|
|
||||||
|
function handleNumpad(key: string) {
|
||||||
|
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const amount = parseAmountStr(amountStr);
|
||||||
|
if (!amount || amount <= 0) return;
|
||||||
|
createTransaction(
|
||||||
|
{ amount, type, scope: defaultScope, categoryId: selectedCategoryId ?? undefined, description: description.trim() || undefined, date: new Date(selectedDate).toISOString(), isFixed, childId: defaultChildId ?? undefined },
|
||||||
|
{ onSuccess: () => { resetState(); onClose(); } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
setAmountStr("0");
|
||||||
|
setDescription("");
|
||||||
|
setSelectedCategoryId(null);
|
||||||
|
setType("expense");
|
||||||
|
setIsFixed(false);
|
||||||
|
setSelectedDate(todayIso());
|
||||||
|
setShowCategoryPicker(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() { resetState(); onClose(); }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={handleClose}>
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<ModalHeader
|
||||||
|
title={tFn('transaction.newBooking')}
|
||||||
|
onClose={handleClose}
|
||||||
|
closeLabel={tFn('common.cancel')}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={tFn('common.save')}
|
||||||
|
saveLoading={isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Type Toggle */}
|
||||||
|
<View className="flex-row mx-4 mt-4 p-1 bg-gray-100 rounded-xl">
|
||||||
|
{(["expense", "income"] as const).map((t) => (
|
||||||
|
<Pressable key={t} onPress={() => { setType(t); setSelectedCategoryId(null); setShowCategoryPicker(false); }}
|
||||||
|
className={`flex-1 py-2 rounded-lg items-center ${type === t ? "bg-white shadow-sm" : ""}`}>
|
||||||
|
<Text className={`font-medium ${type === t ? (t === "expense" ? "text-red-600" : "text-green-600") : "text-gray-500"}`}>
|
||||||
|
{t === "expense" ? tFn('transaction.expense') : tFn('transaction.income')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<View className="items-center py-6">
|
||||||
|
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Category Select Row */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowCategoryPicker((v) => !v)}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row", alignItems: "center",
|
||||||
|
marginHorizontal: 16, marginBottom: 4,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 11,
|
||||||
|
backgroundColor: "#f3f4f6", borderRadius: 12,
|
||||||
|
borderWidth: selectedCategory ? 1.5 : 0,
|
||||||
|
borderColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "#e5e7eb", alignItems: "center", justifyContent: "center", marginRight: 10 }}>
|
||||||
|
<Ionicons
|
||||||
|
name={selectedCategory ? (selectedCategory.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"] : "pricetag-outline"}
|
||||||
|
size={14} color={selectedCategory ? "#fff" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={{ flex: 1, fontSize: 14, color: selectedCategory ? "#111827" : "#9ca3af" }}>
|
||||||
|
{selectedCategory ? selectedCategory.name : tFn('transaction.selectCategory')}
|
||||||
|
</Text>
|
||||||
|
{selectedCategory ? (
|
||||||
|
<Pressable onPress={(e) => { e.stopPropagation(); setSelectedCategoryId(null); }} hitSlop={8}>
|
||||||
|
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={16} color="#9ca3af" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Inline Category Picker */}
|
||||||
|
{showCategoryPicker && (
|
||||||
|
<View style={{ marginHorizontal: 16, marginBottom: 4, borderWidth: 1, borderColor: "#e5e7eb", borderRadius: 12, backgroundColor: "#fff", maxHeight: 220 }}>
|
||||||
|
<ScrollView bounces={false} keyboardShouldPersistTaps="handled">
|
||||||
|
{filteredCategories.map((cat) => {
|
||||||
|
const active = cat.id === selectedCategoryId;
|
||||||
|
const color = cat.color ?? "#6b7280";
|
||||||
|
return (
|
||||||
|
<Pressable key={cat.id}
|
||||||
|
onPress={() => { setSelectedCategoryId(active ? null : cat.id); setShowCategoryPicker(false); }}
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, backgroundColor: active ? `${color}12` : "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||||
|
>
|
||||||
|
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: color, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||||
|
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={16} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ flex: 1, fontSize: 14, fontWeight: "500", color: active ? color : "#111827" }}>{cat.name}</Text>
|
||||||
|
{active && <Ionicons name="checkmark-circle" size={18} color={color} />}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { setShowCategoryPicker(false); onRequestAddCategory(type); }}
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10 }}
|
||||||
|
>
|
||||||
|
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: "#f3f4f6", alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||||
|
<Ionicons name="add" size={16} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "500", color: "#6b7280" }}>{tFn('transaction.addNewCategory')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View className="px-4 mt-2 mb-4">
|
||||||
|
<TextInput className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900" placeholder={tFn('transaction.descriptionOptional')} value={description} onChangeText={setDescription} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Date Row */}
|
||||||
|
<View className="px-4 mb-3 flex-row items-center" style={{ gap: 10 }}>
|
||||||
|
<Ionicons name="calendar-outline" size={20} color="#6b7280" />
|
||||||
|
<Text className="text-base text-gray-700 flex-1">{formatDateDisplay(selectedDate, i18n.language, tFn('common.today'))}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fixkosten Row */}
|
||||||
|
<View className="px-4 mb-4 flex-row items-center" style={{ gap: 10 }}>
|
||||||
|
<Ionicons name="repeat-outline" size={20} color="#6b7280" />
|
||||||
|
<Text className="text-base text-gray-700 flex-1">{tFn('transaction.repeatMonthly')}</Text>
|
||||||
|
<Switch value={isFixed} onValueChange={setIsFixed} trackColor={{ false: "#d1d5db", true: "#2563EB" }} thumbColor="#fff" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Numpad */}
|
||||||
|
<Numpad onKeyPress={handleNumpad} />
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import type { TransactionSummary } from "@/src/hooks/useTransactions";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
summary: TransactionSummary | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
accentColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatEur(amount: number) {
|
||||||
|
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthLabel = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(new Date());
|
||||||
|
|
||||||
|
export function SummaryHeader({ summary, isLoading, accentColor = "#2563EB" }: Props) {
|
||||||
|
const loading = isLoading || !summary;
|
||||||
|
const income = loading ? null : formatEur(summary!.income);
|
||||||
|
const expense = loading ? null : formatEur(summary!.expense);
|
||||||
|
const balance = loading ? null : formatEur(summary!.balance);
|
||||||
|
const balancePositive = !loading && summary!.balance >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-blue-600 px-4 pb-5 pt-3">
|
||||||
|
<Text className="text-center text-blue-200 text-xs mb-3" style={{ opacity: 0.8 }}>
|
||||||
|
{monthLabel}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row">
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-blue-200 text-xs mb-1">Einnahmen</Text>
|
||||||
|
<Text className="text-white font-semibold text-base">
|
||||||
|
{loading ? "—" : income}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="w-px bg-blue-500 mx-2" />
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-blue-200 text-xs mb-1">Ausgaben</Text>
|
||||||
|
<Text className="text-white font-semibold text-base">
|
||||||
|
{loading ? "—" : expense}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="w-px bg-blue-500 mx-2" />
|
||||||
|
<View className="flex-1 items-center">
|
||||||
|
<Text className="text-blue-200 text-xs mb-1">Bilanz</Text>
|
||||||
|
<Text
|
||||||
|
className={`font-semibold text-base ${loading ? "text-blue-300" : balancePositive ? "text-green-300" : "text-red-300"}`}
|
||||||
|
>
|
||||||
|
{loading ? "—" : balance}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||||
|
import Reanimated, { useAnimatedStyle } from "react-native-reanimated";
|
||||||
|
import type { SharedValue } from "react-native-reanimated";
|
||||||
|
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||||
|
|
||||||
|
type IoniconName = ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<string, IoniconName> = {
|
||||||
|
"Lebensmittel": "cart-outline",
|
||||||
|
"Wohnen": "home-outline",
|
||||||
|
"Transport": "car-outline",
|
||||||
|
"Gesundheit": "medkit-outline",
|
||||||
|
"Freizeit": "game-controller-outline",
|
||||||
|
"Kinder": "happy-outline",
|
||||||
|
"Urlaub": "airplane-outline",
|
||||||
|
"Sonstiges": "ellipsis-horizontal-circle-outline",
|
||||||
|
"Gehalt": "briefcase-outline",
|
||||||
|
"Sonstiges Einkommen": "cash-outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveIcon(categoryName: string | null, isIncome: boolean): IoniconName {
|
||||||
|
if (categoryName && CATEGORY_ICONS[categoryName]) return CATEGORY_ICONS[categoryName];
|
||||||
|
return isIncome ? "cash-outline" : "ellipsis-horizontal-circle-outline";
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
transaction: TransactionWithCategory;
|
||||||
|
onPress: (t: TransactionWithCategory) => void;
|
||||||
|
onDelete: (t: TransactionWithCategory) => void;
|
||||||
|
locked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAmount(amount: string, type: "income" | "expense") {
|
||||||
|
const num = parseFloat(amount);
|
||||||
|
const formatted = new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(num);
|
||||||
|
return type === "income" ? `+${formatted}` : `-${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "short" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteAction({
|
||||||
|
prog,
|
||||||
|
drag,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
prog: SharedValue<number>;
|
||||||
|
drag: SharedValue<number>;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const animStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: drag.value + 80 }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Reanimated.View
|
||||||
|
style={[{ width: 80, backgroundColor: "#dc2626", justifyContent: "center", alignItems: "center" }, animStyle]}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={onDelete}
|
||||||
|
style={{ flex: 1, width: "100%", justifyContent: "center", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={20} color="#fff" />
|
||||||
|
<Text style={{ color: "#fff", fontSize: 11, marginTop: 3, fontWeight: "600" }}>{t('common.delete')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Reanimated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionItem({ transaction, onPress, onDelete, locked = false }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isIncome = transaction.type === "income";
|
||||||
|
const isCarryOver = transaction.isCarryOver;
|
||||||
|
|
||||||
|
const iconName: IoniconName = isCarryOver
|
||||||
|
? "return-down-forward-outline"
|
||||||
|
: resolveIcon(transaction.categoryName, isIncome);
|
||||||
|
const iconColor = isCarryOver ? "#6366f1" : (transaction.categoryColor ?? "#6b7280");
|
||||||
|
const bgColor = isCarryOver ? "#6366f122" : (transaction.categoryColor ?? "#6b7280") + "22";
|
||||||
|
|
||||||
|
function handleDeletePress() {
|
||||||
|
const isFixed = transaction.isFixed;
|
||||||
|
const hasDebt = (transaction as TransactionWithCategory & { linkedDebtPaymentId?: string | null }).linkedDebtPaymentId;
|
||||||
|
|
||||||
|
let message = t('transaction.deleteMessage');
|
||||||
|
if (isFixed) message = t('transaction.deleteFixed');
|
||||||
|
if (hasDebt) message = t('transaction.deleteDebt');
|
||||||
|
|
||||||
|
Alert.alert(t('transaction.deleteTitle'), message, [
|
||||||
|
{ text: t('common.cancel'), style: "cancel" },
|
||||||
|
{ text: t('common.delete'), style: "destructive", onPress: () => onDelete(transaction) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CarryOver: kein Swipe, kein Edit
|
||||||
|
if (isCarryOver) {
|
||||||
|
return (
|
||||||
|
<Pressable className="flex-row items-center px-4 py-3 active:bg-gray-50">
|
||||||
|
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||||
|
<Ionicons name={iconName} size={20} color={iconColor} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium" style={{ color: "#6366f1" }} numberOfLines={1}>
|
||||||
|
{transaction.description ?? t('transaction.carryOver')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mt-0.5">{formatDate(transaction.date)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm font-semibold text-indigo-500">
|
||||||
|
{formatAmount(transaction.amount, transaction.type)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locked months: no swipe, no edit — just display
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center px-4 py-3 bg-white">
|
||||||
|
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||||
|
<Ionicons name={iconName} size={20} color={iconColor} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
|
||||||
|
{transaction.description ?? transaction.categoryName ?? "Buchung"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{transaction.categoryName ? `${transaction.categoryName} · ` : ""}
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={`text-sm font-semibold ${isIncome ? "text-green-600" : "text-gray-900"}`}>
|
||||||
|
{formatAmount(transaction.amount, transaction.type)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReanimatedSwipeable
|
||||||
|
friction={2}
|
||||||
|
rightThreshold={40}
|
||||||
|
renderRightActions={(prog, drag) => (
|
||||||
|
<DeleteAction prog={prog} drag={drag} onDelete={handleDeletePress} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onPress(transaction)}
|
||||||
|
className="flex-row items-center px-4 py-3 active:bg-gray-50 bg-white"
|
||||||
|
>
|
||||||
|
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||||
|
<Ionicons name={iconName} size={20} color={iconColor} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
|
||||||
|
{transaction.description ?? transaction.categoryName ?? "Buchung"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{transaction.categoryName ? `${transaction.categoryName} · ` : ""}
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={`text-sm font-semibold ${isIncome ? "text-green-600" : "text-gray-900"}`}>
|
||||||
|
{formatAmount(transaction.amount, transaction.type)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ReanimatedSwipeable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { TAB_COLORS } from "@/src/constants/colors";
|
||||||
|
import { QuickAddModal } from "./QuickAddModal";
|
||||||
|
import { MonthSummaryHeader } from "./MonthSummaryHeader";
|
||||||
|
import { TransactionItem } from "./TransactionItem";
|
||||||
|
import { EditTransactionModal } from "./EditTransactionModal";
|
||||||
|
import { CarryOverBanner } from "./CarryOverBanner";
|
||||||
|
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
|
||||||
|
import { EmptyState } from "@/src/components/ui/EmptyState";
|
||||||
|
import { useTransactions, useMonthBalance, useActivateFixed, useDeleteTransaction } from "@/src/hooks/useTransactions";
|
||||||
|
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||||
|
import type { Category } from "@/src/hooks/useCategories";
|
||||||
|
import { currentMonthStr, addMonths, monthLabel, monthDateRange } from "@/src/utils/date";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type FilterType = "all" | "income" | "expense";
|
||||||
|
type Scope = "household" | "private" | "child";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
scope: Scope;
|
||||||
|
childId?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptySubtitle?: string;
|
||||||
|
disableTopInset?: boolean;
|
||||||
|
headerExtra?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCENT_COLORS: Record<Scope, string> = {
|
||||||
|
household: TAB_COLORS.household,
|
||||||
|
private: TAB_COLORS.private,
|
||||||
|
child: TAB_COLORS.children,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionScreen({
|
||||||
|
scope,
|
||||||
|
childId,
|
||||||
|
accentColor,
|
||||||
|
emptyTitle,
|
||||||
|
emptySubtitle,
|
||||||
|
disableTopInset = false,
|
||||||
|
headerExtra,
|
||||||
|
}: Props) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedEmptyTitle = emptyTitle ?? t('household.noTransactions');
|
||||||
|
const resolvedEmptySubtitle = emptySubtitle ?? t('household.noTransactionsHint');
|
||||||
|
const [filter, setFilter] = useState<FilterType>("all");
|
||||||
|
const [month, setMonth] = useState(currentMonthStr());
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [showAddCategory, setShowAddCategory] = useState(false);
|
||||||
|
const [addCategoryType, setAddCategoryType] = useState<"expense" | "income">("expense");
|
||||||
|
const [newCategory, setNewCategory] = useState<Category | null>(null);
|
||||||
|
const [editTransaction, setEditTransaction] = useState<TransactionWithCategory | null>(null);
|
||||||
|
const { mutate: deleteTransaction } = useDeleteTransaction();
|
||||||
|
|
||||||
|
const color = accentColor ?? ACCENT_COLORS[scope];
|
||||||
|
const isCurrent = month === currentMonthStr();
|
||||||
|
|
||||||
|
// 11a: activate fixed transactions silently on mount + when month changes to current
|
||||||
|
const { mutate: activateFixed } = useActivateFixed();
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCurrent) {
|
||||||
|
activateFixed({ month, scope, ...(childId ? { childId } : {}) });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [month, scope, childId]);
|
||||||
|
|
||||||
|
const [fromDate, toDate] = monthDateRange(month);
|
||||||
|
|
||||||
|
const transactionFilter = {
|
||||||
|
scope,
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate,
|
||||||
|
...(childId ? { childId } : {}),
|
||||||
|
...(filter !== "all" ? { type: filter as "income" | "expense" } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: transactions = [], isLoading, refetch, isRefetching } = useTransactions(transactionFilter);
|
||||||
|
const { data: balance, isLoading: balanceLoading } = useMonthBalance(scope, month, childId);
|
||||||
|
|
||||||
|
function renderEmpty() {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center py-20">
|
||||||
|
<ActivityIndicator size="large" color={color} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="wallet-outline"
|
||||||
|
title={resolvedEmptyTitle}
|
||||||
|
subtitle={resolvedEmptySubtitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Neutral header — paddingTop for safe area when used as top-level screen */}
|
||||||
|
<View style={{ backgroundColor: "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6", paddingTop: disableTopInset ? 0 : insets.top }}>
|
||||||
|
{/* Month Switcher */}
|
||||||
|
<View className="flex-row items-center justify-center gap-4 py-3">
|
||||||
|
<Pressable onPress={() => setMonth((m) => addMonths(m, -1))} className="p-1 active:opacity-50">
|
||||||
|
<Ionicons name="chevron-back" size={18} color="#6b7280" />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-sm font-semibold w-32 text-center text-gray-800">
|
||||||
|
{monthLabel(month)}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setMonth((m) => addMonths(m, 1))}
|
||||||
|
disabled={isCurrent}
|
||||||
|
className="p-1 active:opacity-50"
|
||||||
|
style={{ opacity: isCurrent ? 0.3 : 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#6b7280" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{headerExtra}
|
||||||
|
|
||||||
|
<MonthSummaryHeader
|
||||||
|
income={balance?.income}
|
||||||
|
expense={balance?.expense}
|
||||||
|
balance={balance?.balance}
|
||||||
|
isLoading={balanceLoading}
|
||||||
|
accentColor={color}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<View className="flex-row px-4 py-3 gap-2 bg-white border-b border-gray-100 mt-3">
|
||||||
|
{(["all", "expense", "income"] as const).map((f) => (
|
||||||
|
<Pressable
|
||||||
|
key={f}
|
||||||
|
onPress={() => setFilter(f)}
|
||||||
|
style={{ backgroundColor: filter === f ? color : "#f3f4f6" }}
|
||||||
|
className="px-4 py-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: filter === f ? "#fff" : "#4b5563" }}
|
||||||
|
>
|
||||||
|
{f === "all" ? t('household.all') : f === "expense" ? t('household.expenses') : t('household.income')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<FlatList
|
||||||
|
data={transactions}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View className="bg-white">
|
||||||
|
<TransactionItem
|
||||||
|
transaction={item}
|
||||||
|
onPress={setEditTransaction}
|
||||||
|
onDelete={(t) => deleteTransaction(t.id)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListHeaderComponent={
|
||||||
|
!isCurrent ? (
|
||||||
|
<CarryOverBanner month={month} scope={scope} childId={childId} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isRefetching} onRefresh={() => void refetch()} tintColor={color} />
|
||||||
|
}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-px bg-gray-50 ml-16" />}
|
||||||
|
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAddModal(true)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
className="absolute bottom-6 right-6 w-14 h-14 rounded-full items-center justify-center shadow-lg active:opacity-80"
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={28} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<QuickAddModal
|
||||||
|
visible={showAddModal}
|
||||||
|
onClose={() => { setShowAddModal(false); setNewCategory(null); }}
|
||||||
|
onRequestAddCategory={(t) => { setAddCategoryType(t); setShowAddModal(false); setShowAddCategory(true); }}
|
||||||
|
newCategory={newCategory}
|
||||||
|
defaultScope={scope}
|
||||||
|
defaultChildId={childId}
|
||||||
|
/>
|
||||||
|
<AddCategoryModal
|
||||||
|
visible={showAddCategory}
|
||||||
|
onClose={() => { setShowAddCategory(false); setShowAddModal(true); }}
|
||||||
|
defaultType={addCategoryType}
|
||||||
|
onCreated={(cat) => { setNewCategory(cat); setShowAddCategory(false); setShowAddModal(true); }}
|
||||||
|
/>
|
||||||
|
{editTransaction && (
|
||||||
|
<EditTransactionModal
|
||||||
|
transaction={editTransaction}
|
||||||
|
onClose={() => setEditTransaction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/native/src/components/ui/EmptyState.tsx
Normal file
20
apps/native/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon: ComponentProps<typeof Ionicons>["name"];
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
iconSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyState({ icon, title, subtitle, iconSize = 48 }: Props) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center py-20">
|
||||||
|
<Ionicons name={icon} size={iconSize} color="#d1d5db" style={{ marginBottom: 12 }} />
|
||||||
|
<Text className="text-base font-medium text-gray-700 mb-1">{title}</Text>
|
||||||
|
<Text className="text-sm text-gray-400 text-center px-8">{subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/native/src/components/ui/ErrorMessage.tsx
Normal file
13
apps/native/src/components/ui/ErrorMessage.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
type ErrorMessageProps = {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ErrorMessage({ message }: ErrorMessageProps) {
|
||||||
|
return (
|
||||||
|
<View className="rounded-lg bg-red-50 p-4">
|
||||||
|
<Text className="text-red-600">{message}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/native/src/components/ui/LoadingSpinner.tsx
Normal file
14
apps/native/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ActivityIndicator, View } from "react-native";
|
||||||
|
|
||||||
|
type LoadingSpinnerProps = {
|
||||||
|
size?: "small" | "large";
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingSpinner({ size = "large", color = "#9ca3af" }: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size={size} color={color} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/native/src/components/ui/ModalHeader.tsx
Normal file
49
apps/native/src/components/ui/ModalHeader.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
|
import { TAB_COLORS } from "@/src/constants/colors";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
closeLabel: string;
|
||||||
|
onSave?: () => void;
|
||||||
|
saveLabel?: string;
|
||||||
|
saveDisabled?: boolean;
|
||||||
|
saveLoading?: boolean;
|
||||||
|
saveColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ModalHeader({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
closeLabel,
|
||||||
|
onSave,
|
||||||
|
saveLabel,
|
||||||
|
saveDisabled = false,
|
||||||
|
saveLoading = false,
|
||||||
|
saveColor = TAB_COLORS.household,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-4 border-b border-gray-100">
|
||||||
|
<Pressable onPress={onClose}>
|
||||||
|
<Text className="text-base text-gray-500">{closeLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-base font-semibold text-gray-900">{title}</Text>
|
||||||
|
{onSave ? (
|
||||||
|
<Pressable onPress={onSave} disabled={saveLoading || saveDisabled}>
|
||||||
|
{saveLoading ? (
|
||||||
|
<ActivityIndicator size="small" color={saveColor} />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
className="text-base font-semibold"
|
||||||
|
style={{ color: saveDisabled ? "#9ca3af" : saveColor }}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View style={{ width: 40 }} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/native/src/components/ui/Numpad.tsx
Normal file
31
apps/native/src/components/ui/Numpad.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { NUMPAD_KEYS } from "@/src/utils/numpad";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onKeyPress: (key: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Numpad({ onKeyPress }: Props) {
|
||||||
|
return (
|
||||||
|
<View className="px-4">
|
||||||
|
{NUMPAD_KEYS.map((row, i) => (
|
||||||
|
<View key={i} className="flex-row gap-2 mb-2">
|
||||||
|
{row.map((key) => (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
onPress={() => onKeyPress(key)}
|
||||||
|
className="flex-1 h-14 bg-gray-100 rounded-xl items-center justify-center active:bg-gray-200"
|
||||||
|
>
|
||||||
|
{key === "\u232B" ? (
|
||||||
|
<Ionicons name="backspace-outline" size={20} color="#374151" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-xl font-medium text-gray-800">{key}</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/native/src/constants/colors.ts
Normal file
7
apps/native/src/constants/colors.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const TAB_COLORS = {
|
||||||
|
household: "#2563EB",
|
||||||
|
private: "#7C3AED",
|
||||||
|
children: "#16A34A",
|
||||||
|
shopping: "#16A34A",
|
||||||
|
more: "#6B7280",
|
||||||
|
} as const;
|
||||||
31
apps/native/src/hooks/useApi.ts
Normal file
31
apps/native/src/hooks/useApi.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
|
||||||
|
type ApiState<T> = {
|
||||||
|
data: T | null;
|
||||||
|
error: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useApi<T>() {
|
||||||
|
const [state, setState] = useState<ApiState<T>>({
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const execute = useCallback(async (path: string, options?: RequestInit) => {
|
||||||
|
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const data = await apiRequest<T>(path, options);
|
||||||
|
setState({ data, error: null, isLoading: false });
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setState({ data: null, error, isLoading: false });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, execute };
|
||||||
|
}
|
||||||
104
apps/native/src/hooks/useCategories.ts
Normal file
104
apps/native/src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import type { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
type IoniconName = ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
// Icon mapping by category name — DB icon field is not used for rendering
|
||||||
|
export const CATEGORY_ICONS: Record<string, IoniconName> = {
|
||||||
|
"Lebensmittel": "cart-outline",
|
||||||
|
"Wohnen": "home-outline",
|
||||||
|
"Transport": "car-outline",
|
||||||
|
"Gesundheit": "medkit-outline",
|
||||||
|
"Freizeit": "game-controller-outline",
|
||||||
|
"Kinder": "happy-outline",
|
||||||
|
"Urlaub": "airplane-outline",
|
||||||
|
"Sonstiges": "ellipsis-horizontal-circle-outline",
|
||||||
|
"Gehalt": "briefcase-outline",
|
||||||
|
"Sonstiges Einkommen": "cash-outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EXPENSE_ICON: IoniconName = "ellipsis-horizontal-circle-outline";
|
||||||
|
const DEFAULT_INCOME_ICON: IoniconName = "cash-outline";
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: IoniconName;
|
||||||
|
color: string | null;
|
||||||
|
type: "income" | "expense";
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiCategory = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
color: string | null;
|
||||||
|
type: "income" | "expense";
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapCategory(cat: ApiCategory): Category {
|
||||||
|
return {
|
||||||
|
...cat,
|
||||||
|
icon:
|
||||||
|
CATEGORY_ICONS[cat.name] ??
|
||||||
|
(cat.type === "income" ? DEFAULT_INCOME_ICON : DEFAULT_EXPENSE_ICON),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["categories", activeHouseholdId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiRequest<{ categories: ApiCategory[] }>("/api/households/categories"),
|
||||||
|
select: (data) => data.categories.map(mapCategory),
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCategory() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { name: string; icon?: string | null; color?: string | null; type: "income" | "expense" }) =>
|
||||||
|
apiRequest<{ category: ApiCategory }>("/api/categories", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}).then((r) => mapCategory(r.category)),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["categories", activeHouseholdId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCategory() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: { id: string; name?: string; icon?: string | null; color?: string | null }) =>
|
||||||
|
apiRequest<{ category: ApiCategory }>(`/api/categories/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}).then((r) => mapCategory(r.category)),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["categories", activeHouseholdId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCategory() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ deleted: boolean }>(`/api/categories/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["categories", activeHouseholdId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
54
apps/native/src/hooks/useChildren.ts
Normal file
54
apps/native/src/hooks/useChildren.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
export type Child = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
householdId: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateChildInput = {
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChildren() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["children", activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ children: Child[] }>("/api/children"),
|
||||||
|
select: (data) => data.children,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateChild() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateChildInput) =>
|
||||||
|
apiRequest<{ child: Child }>("/api/children", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["children"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteChild() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ child: Child }>(`/api/children/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["children"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
109
apps/native/src/hooks/useDebts.ts
Normal file
109
apps/native/src/hooks/useDebts.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
export type Debt = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
userId: string;
|
||||||
|
creditorUserId: string | null;
|
||||||
|
creditorUserName: string | null;
|
||||||
|
label: string;
|
||||||
|
creditor: string | null;
|
||||||
|
totalAmount: number;
|
||||||
|
paidAmount: number;
|
||||||
|
remainingAmount: number;
|
||||||
|
progressPercent: number;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
closedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DebtPayment = {
|
||||||
|
id: string;
|
||||||
|
debtId: string;
|
||||||
|
amount: number;
|
||||||
|
date: string;
|
||||||
|
note: string | null;
|
||||||
|
linkedTransactionId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateDebtInput = {
|
||||||
|
label: string;
|
||||||
|
creditorUserId?: string;
|
||||||
|
creditor?: string;
|
||||||
|
totalAmount: number;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateDebtPaymentInput = {
|
||||||
|
debtId: string;
|
||||||
|
amount: number;
|
||||||
|
date: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDebts() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["debts", activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ debts: Debt[] }>("/api/debts"),
|
||||||
|
select: (data) => data.debts,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClaims() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["debts-claims", activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ debts: Debt[] }>("/api/debts/claims"),
|
||||||
|
select: (data) => data.debts,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateDebt() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateDebtInput) =>
|
||||||
|
apiRequest<{ debt: Debt }>("/api/debts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["debts"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteDebt() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ success: boolean }>(`/api/debts/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["debts"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateDebtPayment() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateDebtPaymentInput) =>
|
||||||
|
apiRequest<{ payment: DebtPayment; debt: Debt }>("/api/debts/payments", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["debts"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["debts-claims"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
210
apps/native/src/hooks/useFixedCosts.ts
Normal file
210
apps/native/src/hooks/useFixedCosts.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
export type FixedCost = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
scope: "household" | "private" | "child";
|
||||||
|
childId: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
type: "income" | "expense";
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransferLineItem = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MonthlyTransfer = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
month: string;
|
||||||
|
fromUserId: string;
|
||||||
|
toUserId: string;
|
||||||
|
amount: number;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NettoMonth = {
|
||||||
|
month: string;
|
||||||
|
totalIncome: number;
|
||||||
|
incomeByScope: Array<{ scope: string; label: string; amount: number }>;
|
||||||
|
totalExpenses: number;
|
||||||
|
netto: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettlementV2 = {
|
||||||
|
month: string;
|
||||||
|
householdExpenses: number;
|
||||||
|
householdIncome: number;
|
||||||
|
householdNet: number;
|
||||||
|
memberCount: number;
|
||||||
|
perMemberShare: number;
|
||||||
|
userSharePercent: number;
|
||||||
|
lineItems: Array<{ id: string; label: string; amount: number }>;
|
||||||
|
lineItemsTotal: number;
|
||||||
|
myOwnExpenses: number;
|
||||||
|
transfers: MonthlyTransfer[];
|
||||||
|
alreadyTransferred: number;
|
||||||
|
totalOwed: number;
|
||||||
|
remaining: number;
|
||||||
|
members: Array<{ userId: string; name: string; paid: number; owes: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateFixedCostInput = {
|
||||||
|
scope: "household" | "private" | "child";
|
||||||
|
childId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
type?: "income" | "expense";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateFixedCostInput = {
|
||||||
|
label?: string;
|
||||||
|
amount?: number;
|
||||||
|
categoryId?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Fixed Costs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useFixedCosts() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["fixed-costs", activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ fixedCosts: FixedCost[] }>("/api/fixed-costs"),
|
||||||
|
select: (data) => data.fixedCosts,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFixedCost() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateFixedCostInput) =>
|
||||||
|
apiRequest<{ fixedCost: FixedCost }>("/api/fixed-costs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["fixed-costs"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateFixedCost() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: string; input: UpdateFixedCostInput }) =>
|
||||||
|
apiRequest<{ fixedCost: FixedCost }>(`/api/fixed-costs/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["fixed-costs"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFixedCost() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ success: boolean }>(`/api/fixed-costs/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["fixed-costs"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transfer Line Items ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useTransferLineItems() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["transfer-line-items", activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ lineItems: TransferLineItem[] }>("/api/fixed-costs/line-items"),
|
||||||
|
select: (data) => data.lineItems,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTransferLineItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: { label: string; amount: number }) =>
|
||||||
|
apiRequest<{ lineItem: TransferLineItem }>("/api/fixed-costs/line-items", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transfer-line-items"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["settlement-v2"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteTransferLineItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ success: boolean }>(`/api/fixed-costs/line-items/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transfer-line-items"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["settlement-v2"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Monthly Transfers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateMonthlyTransfer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: { month: string; toUserId: string; amount: number; note?: string }) =>
|
||||||
|
apiRequest<{ transfer: MonthlyTransfer }>("/api/fixed-costs/monthly-transfers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["settlement-v2"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Netto Month ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useNettoMonth(month: string) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["netto-month", activeHouseholdId, month],
|
||||||
|
queryFn: () =>
|
||||||
|
apiRequest<{ netto: NettoMonth | null }>(`/api/fixed-costs/netto/${month}`),
|
||||||
|
select: (data) => data.netto,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settlement V2 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useSettlementV2(month: string) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["settlement-v2", activeHouseholdId, month],
|
||||||
|
queryFn: () =>
|
||||||
|
apiRequest<{ settlement: SettlementV2 }>(`/api/fixed-costs/settlement/${month}`),
|
||||||
|
select: (data) => data.settlement,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
79
apps/native/src/hooks/useHouseholdMembers.ts
Normal file
79
apps/native/src/hooks/useHouseholdMembers.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
import { authClient } from "../lib/auth-client";
|
||||||
|
|
||||||
|
export type HouseholdMember = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PendingInvitation = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string | null;
|
||||||
|
status: string;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MembersResponse = {
|
||||||
|
members: HouseholdMember[];
|
||||||
|
pendingInvitations: PendingInvitation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useHouseholdMembers() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["household-members", activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<MembersResponse>("/api/households/members"),
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInviteMember() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (email: string) => {
|
||||||
|
const result = await authClient.organization.inviteMember({
|
||||||
|
email,
|
||||||
|
role: "member",
|
||||||
|
organizationId: activeHouseholdId!,
|
||||||
|
});
|
||||||
|
if (result.error) throw new Error(result.error.message ?? "Einladung fehlgeschlagen");
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["household-members", activeHouseholdId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevokeInvitation() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (invitationId: string) =>
|
||||||
|
apiRequest(`/api/households/invitations/${invitationId}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["household-members", activeHouseholdId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAcceptInvitation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (invitationId: string) => {
|
||||||
|
const result = await authClient.organization.acceptInvitation({ invitationId });
|
||||||
|
if (result.error) throw new Error(result.error.message ?? "Annahme fehlgeschlagen");
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
58
apps/native/src/hooks/useHouseholdSettings.ts
Normal file
58
apps/native/src/hooks/useHouseholdSettings.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
export type HouseholdSettings = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
ownerName: string;
|
||||||
|
partnerName: string;
|
||||||
|
userSharePercent: number;
|
||||||
|
monthlyBudget: number;
|
||||||
|
currency: string;
|
||||||
|
splitChildCosts: boolean;
|
||||||
|
payerUserId: string | null;
|
||||||
|
onboardingComplete: boolean;
|
||||||
|
language: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateHouseholdSettingsInput = {
|
||||||
|
ownerName?: string;
|
||||||
|
partnerName?: string;
|
||||||
|
userSharePercent?: number;
|
||||||
|
monthlyBudget?: number;
|
||||||
|
currency?: string;
|
||||||
|
splitChildCosts?: boolean;
|
||||||
|
payerUserId?: string | null;
|
||||||
|
onboardingComplete?: boolean;
|
||||||
|
language?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useHouseholdSettings() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["household-settings", activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ settings: HouseholdSettings }>("/api/household-settings"),
|
||||||
|
select: (data) => data.settings,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateHouseholdSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: UpdateHouseholdSettingsInput) =>
|
||||||
|
apiRequest<{ settings: HouseholdSettings }>("/api/household-settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["household-settings", activeHouseholdId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["netto-month"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["settlement-v2"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
31
apps/native/src/hooks/useInvite.ts
Normal file
31
apps/native/src/hooks/useInvite.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
|
||||||
|
type GenerateInviteCodeResponse = {
|
||||||
|
code: string;
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JoinWithCodeResponse = {
|
||||||
|
householdId: string;
|
||||||
|
householdName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGenerateInviteCode() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiRequest<GenerateInviteCodeResponse>("/api/households/invite/generate", {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJoinWithCode() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (code: string) =>
|
||||||
|
apiRequest<JoinWithCodeResponse>("/api/households/invite/join", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
50
apps/native/src/hooks/useMonthStatus.ts
Normal file
50
apps/native/src/hooks/useMonthStatus.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
export type MonthStatus = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
month: string;
|
||||||
|
status: "open" | "closed";
|
||||||
|
closedAt: string | null;
|
||||||
|
closedBy: string | null;
|
||||||
|
finalAmount: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
finalTransferId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMonthStatus(month: string) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["month-status", activeHouseholdId, month],
|
||||||
|
queryFn: () =>
|
||||||
|
apiRequest<{ status: MonthStatus }>(`/api/months/${month}/status`),
|
||||||
|
select: (data) => data.status,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloseMonthInput = {
|
||||||
|
finalAmount: number;
|
||||||
|
toUserId: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCloseMonth(month: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CloseMonthInput) =>
|
||||||
|
apiRequest<{ status: MonthStatus }>(`/api/months/${month}/close`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["month-status", activeHouseholdId, month] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["settlement-v2", activeHouseholdId, month] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transactions", activeHouseholdId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
28
apps/native/src/hooks/useSettlement.ts
Normal file
28
apps/native/src/hooks/useSettlement.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
export type SettlementMember = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
paid: number;
|
||||||
|
owes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Settlement = {
|
||||||
|
month: string;
|
||||||
|
totalExpenses: number;
|
||||||
|
memberCount: number;
|
||||||
|
perMember: number;
|
||||||
|
members: SettlementMember[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSettlement(month: string) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["settlement", activeHouseholdId, month],
|
||||||
|
queryFn: () =>
|
||||||
|
apiRequest<Settlement>(`/api/households/settlement?month=${month}`),
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
224
apps/native/src/hooks/useShoppingList.ts
Normal file
224
apps/native/src/hooks/useShoppingList.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { env } from "@haushaltsApp/env/native";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
// Mirroring @haushaltsApp/shared/schemas/shopping.schema types
|
||||||
|
// (workspace package exports are not resolved by Expo's TS config)
|
||||||
|
export type ShoppingItem = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
label: string;
|
||||||
|
quantity: string | null;
|
||||||
|
addedBy: string;
|
||||||
|
checkedBy: string | null;
|
||||||
|
checkedAt: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShoppingServerEvent =
|
||||||
|
| { type: "item:added"; item: ShoppingItem }
|
||||||
|
| { type: "item:checked"; itemId: string; checkedBy: string; checkedAt: string }
|
||||||
|
| { type: "item:unchecked"; itemId: string }
|
||||||
|
| { type: "item:deleted"; itemId: string }
|
||||||
|
| { type: "item:cleared" }
|
||||||
|
| { type: "sync"; items: ShoppingItem[] };
|
||||||
|
|
||||||
|
type ShoppingClientCommand =
|
||||||
|
| { type: "item:add"; label: string; quantity?: string }
|
||||||
|
| { type: "item:check"; itemId: string }
|
||||||
|
| { type: "item:uncheck"; itemId: string }
|
||||||
|
| { type: "item:delete"; itemId: string }
|
||||||
|
| { type: "item:clear" };
|
||||||
|
|
||||||
|
// expoClient plugin stores the session token under "<storagePrefix>.session_token"
|
||||||
|
const TOKEN_KEY = "haushaltsapp.session_token";
|
||||||
|
const MAX_BACKOFF_MS = 16_000;
|
||||||
|
|
||||||
|
function getWsUrl(serverUrl: string, householdId: string, token: string): string {
|
||||||
|
// Convert http(s) to ws(s) for WebSocket connection
|
||||||
|
const base = serverUrl.replace(/^http/, "ws");
|
||||||
|
return `${base}/api/shopping-lists/ws?householdId=${encodeURIComponent(householdId)}&token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionStatus = "connecting" | "connected" | "offline";
|
||||||
|
|
||||||
|
export function useShoppingList() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const [items, setItems] = useState<ShoppingItem[]>([]);
|
||||||
|
const [status, setStatus] = useState<ConnectionStatus>("connecting");
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const backoffRef = useRef(1_000);
|
||||||
|
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const offlineQueueRef = useRef<ShoppingClientCommand[]>([]);
|
||||||
|
|
||||||
|
const send = useCallback((cmd: ShoppingClientCommand) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(cmd));
|
||||||
|
} else {
|
||||||
|
offlineQueueRef.current.push(cmd);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleServerEvent(event: ShoppingServerEvent) {
|
||||||
|
switch (event.type) {
|
||||||
|
case "sync":
|
||||||
|
setItems(event.items);
|
||||||
|
break;
|
||||||
|
case "item:added":
|
||||||
|
setItems((prev) => {
|
||||||
|
// Replace optimistic placeholder (keyed by label) or append
|
||||||
|
const withoutTemp = prev.filter((i) => i.id !== `temp-${event.item.label}`);
|
||||||
|
return [...withoutTemp, event.item];
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "item:checked":
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === event.itemId
|
||||||
|
? { ...i, checkedBy: event.checkedBy, checkedAt: event.checkedAt }
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "item:unchecked":
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === event.itemId ? { ...i, checkedBy: null, checkedAt: null } : i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "item:deleted":
|
||||||
|
setItems((prev) => prev.filter((i) => i.id !== event.itemId));
|
||||||
|
break;
|
||||||
|
case "item:cleared":
|
||||||
|
setItems((prev) => prev.filter((i) => i.checkedBy === null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
if (!activeHouseholdId || !mountedRef.current) return;
|
||||||
|
|
||||||
|
const token = (await SecureStore.getItemAsync(TOKEN_KEY)) ?? "";
|
||||||
|
if (!token) return;
|
||||||
|
const wsUrl = getWsUrl(env.EXPO_PUBLIC_SERVER_URL, activeHouseholdId, token);
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
setStatus("connecting");
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("connected");
|
||||||
|
backoffRef.current = 1_000;
|
||||||
|
// Flush offline queue
|
||||||
|
const queue = offlineQueueRef.current.splice(0);
|
||||||
|
for (const cmd of queue) ws.send(JSON.stringify(cmd));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
try {
|
||||||
|
const serverEvent = JSON.parse(event.data as string) as ShoppingServerEvent;
|
||||||
|
handleServerEvent(serverEvent);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setStatus("offline");
|
||||||
|
wsRef.current = null;
|
||||||
|
retryTimeoutRef.current = setTimeout(() => {
|
||||||
|
backoffRef.current = Math.min(backoffRef.current * 2, MAX_BACKOFF_MS);
|
||||||
|
void connect();
|
||||||
|
}, backoffRef.current);
|
||||||
|
};
|
||||||
|
}, [activeHouseholdId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
void connect();
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current);
|
||||||
|
wsRef.current?.close();
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
// ── Mutations (optimistic + WS) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const addItem = useCallback(
|
||||||
|
(label: string, quantity?: string) => {
|
||||||
|
const trimmed = label.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
// Optimistic placeholder
|
||||||
|
const tempItem: ShoppingItem = {
|
||||||
|
id: `temp-${trimmed}`,
|
||||||
|
householdId: activeHouseholdId ?? "",
|
||||||
|
label: trimmed,
|
||||||
|
quantity: quantity ?? null,
|
||||||
|
addedBy: "",
|
||||||
|
checkedBy: null,
|
||||||
|
checkedAt: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setItems((prev) => [...prev, tempItem]);
|
||||||
|
send({ type: "item:add", label: trimmed, quantity });
|
||||||
|
},
|
||||||
|
[activeHouseholdId, send],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleItem = useCallback(
|
||||||
|
(item: ShoppingItem) => {
|
||||||
|
const isChecked = item.checkedBy !== null;
|
||||||
|
// Optimistic update
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === item.id
|
||||||
|
? isChecked
|
||||||
|
? { ...i, checkedBy: null, checkedAt: null }
|
||||||
|
: { ...i, checkedBy: "optimistic", checkedAt: new Date().toISOString() }
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
isChecked
|
||||||
|
? { type: "item:uncheck", itemId: item.id }
|
||||||
|
: { type: "item:check", itemId: item.id },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[send],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteItem = useCallback(
|
||||||
|
(itemId: string) => {
|
||||||
|
// Optimistic update
|
||||||
|
setItems((prev) => prev.filter((i) => i.id !== itemId));
|
||||||
|
send({ type: "item:delete", itemId });
|
||||||
|
},
|
||||||
|
[send],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteChecked = useCallback(() => {
|
||||||
|
// Optimistic update
|
||||||
|
setItems((prev) => prev.filter((i) => i.checkedBy === null));
|
||||||
|
send({ type: "item:clear" });
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
return { items, status, addItem, toggleItem, deleteItem, deleteChecked };
|
||||||
|
}
|
||||||
174
apps/native/src/hooks/useTransactions.ts
Normal file
174
apps/native/src/hooks/useTransactions.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
import { monthDateRange } from "../utils/date";
|
||||||
|
import type {
|
||||||
|
CreateTransactionInput,
|
||||||
|
TransactionFilters,
|
||||||
|
} from "@haushaltsApp/shared/schemas/transaction";
|
||||||
|
|
||||||
|
export type TransactionWithCategory = {
|
||||||
|
id: string;
|
||||||
|
amount: string;
|
||||||
|
type: "income" | "expense";
|
||||||
|
scope: "household" | "private" | "child";
|
||||||
|
childId: string | null;
|
||||||
|
isFixed: boolean;
|
||||||
|
isCarryOver: boolean;
|
||||||
|
description: string | null;
|
||||||
|
merchant: string | null;
|
||||||
|
date: string;
|
||||||
|
categoryName: string | null;
|
||||||
|
categoryIcon: string | null;
|
||||||
|
categoryColor: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransactionSummary = {
|
||||||
|
income: number;
|
||||||
|
expense: number;
|
||||||
|
balance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTransactions(filters?: Partial<TransactionFilters>) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.type) params.set("type", filters.type);
|
||||||
|
if (filters?.categoryId) params.set("categoryId", filters.categoryId);
|
||||||
|
if (filters?.scope) params.set("scope", filters.scope);
|
||||||
|
if (filters?.childId) params.set("childId", filters.childId);
|
||||||
|
if (filters?.from) params.set("from", filters.from);
|
||||||
|
if (filters?.to) params.set("to", filters.to);
|
||||||
|
const query = params.toString();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["transactions", activeHouseholdId, filters],
|
||||||
|
queryFn: () =>
|
||||||
|
apiRequest<{ transactions: TransactionWithCategory[] }>(
|
||||||
|
`/api/transactions${query ? `?${query}` : ""}`,
|
||||||
|
),
|
||||||
|
select: (data) => data.transactions,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactionSummary(scope?: "household" | "private" | "child") {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const query = scope ? `?scope=${scope}` : "";
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["transactions", "summary", activeHouseholdId, scope],
|
||||||
|
queryFn: () => apiRequest<TransactionSummary>(`/api/transactions/summary${query}`),
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTransaction() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateTransactionInput) =>
|
||||||
|
apiRequest<{ transaction: TransactionWithCategory }>("/api/transactions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteTransaction() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest(`/api/transactions/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTransaction() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...input }: { id: string } & Partial<CreateTransactionInput>) =>
|
||||||
|
apiRequest<{ transaction: TransactionWithCategory }>(`/api/transactions/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivateFixed() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
month: string;
|
||||||
|
scope: "household" | "private" | "child";
|
||||||
|
childId?: string;
|
||||||
|
}) =>
|
||||||
|
apiRequest<{ created: number }>("/api/transactions/activate-fixed", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
}),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.created > 0) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCarryOver() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
fromMonth: string;
|
||||||
|
toMonth: string;
|
||||||
|
scope: "household" | "private" | "child";
|
||||||
|
childId?: string;
|
||||||
|
}) =>
|
||||||
|
apiRequest<{ transaction: TransactionWithCategory }>("/api/transactions/carry-over", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMonthBalance(
|
||||||
|
scope: "household" | "private" | "child",
|
||||||
|
month: string,
|
||||||
|
childId?: string,
|
||||||
|
) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
const [from, to] = monthDateRange(month);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ scope, from, to });
|
||||||
|
if (childId) params.set("childId", childId);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["transactions", "balance", activeHouseholdId, scope, month, childId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiRequest<{ transactions: TransactionWithCategory[] }>(
|
||||||
|
`/api/transactions?${params.toString()}`,
|
||||||
|
).then((data) => {
|
||||||
|
let income = 0;
|
||||||
|
let expense = 0;
|
||||||
|
for (const tx of data.transactions) {
|
||||||
|
if (tx.type === "income") income += parseFloat(tx.amount);
|
||||||
|
else expense += parseFloat(tx.amount);
|
||||||
|
}
|
||||||
|
return { income, expense, balance: income - expense };
|
||||||
|
}),
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
242
apps/native/src/hooks/useTrips.ts
Normal file
242
apps/native/src/hooks/useTrips.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "../lib/api-client";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Trip = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
destination: string | null;
|
||||||
|
budget: number;
|
||||||
|
startDate: string; // YYYY-MM-DD
|
||||||
|
endDate: string; // YYYY-MM-DD
|
||||||
|
status: "active" | "completed";
|
||||||
|
spent: number; // computed by server
|
||||||
|
createdAt: string;
|
||||||
|
settlementFromUserId: string | null;
|
||||||
|
settlementToUserId: string | null;
|
||||||
|
settlementAmount: number | null;
|
||||||
|
settledAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TripSettlement = {
|
||||||
|
total: number;
|
||||||
|
fairShare: number;
|
||||||
|
balances: Array<{
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
paid: number;
|
||||||
|
fairShare: number;
|
||||||
|
balance: number;
|
||||||
|
}>;
|
||||||
|
settlement: {
|
||||||
|
from: string;
|
||||||
|
fromName: string;
|
||||||
|
to: string;
|
||||||
|
toName: string;
|
||||||
|
amount: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TripExpense = {
|
||||||
|
id: string;
|
||||||
|
tripId: string;
|
||||||
|
householdId: string;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
category: "unterkunft" | "essen" | "transport" | "aktivitaeten" | "sonstiges";
|
||||||
|
paidBy: string; // userId
|
||||||
|
date: string;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TripSummary = {
|
||||||
|
trip: Trip;
|
||||||
|
totalSpent: number;
|
||||||
|
remaining: number;
|
||||||
|
byCategory: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateTripInput = {
|
||||||
|
name: string;
|
||||||
|
destination?: string;
|
||||||
|
budget: number;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateTripInput = {
|
||||||
|
name?: string;
|
||||||
|
destination?: string | null;
|
||||||
|
budget?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateTripExpenseInput = {
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
category: TripExpense["category"];
|
||||||
|
paidBy: string;
|
||||||
|
date: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateTripExpenseInput = {
|
||||||
|
label?: string;
|
||||||
|
amount?: number;
|
||||||
|
category?: TripExpense["category"];
|
||||||
|
paidBy?: string;
|
||||||
|
date?: string;
|
||||||
|
note?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Trip Queries ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useTrips() {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["trips", activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ trips: Trip[] }>("/api/trips"),
|
||||||
|
select: (data) => data.trips,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrip(id: string) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["trip-summary", id, activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ summary: TripSummary }>(`/api/trips/${id}/summary`),
|
||||||
|
select: (data) => data.summary,
|
||||||
|
enabled: !!activeHouseholdId && !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTripExpenses(tripId: string) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["trip-expenses", tripId, activeHouseholdId],
|
||||||
|
queryFn: () => apiRequest<{ expenses: TripExpense[] }>(`/api/trips/${tripId}/expenses`),
|
||||||
|
select: (data) => data.expenses,
|
||||||
|
enabled: !!activeHouseholdId && !!tripId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTripSettlement(tripId: string) {
|
||||||
|
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["trip-settlement", tripId, activeHouseholdId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiRequest<{ settlement: TripSettlement }>(`/api/trips/${tripId}/settlement`),
|
||||||
|
select: (data) => data.settlement,
|
||||||
|
enabled: !!tripId && !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trip Mutations ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateTrip() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateTripInput) =>
|
||||||
|
apiRequest<{ trip: Trip }>("/api/trips", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTrip(id: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: UpdateTripInput) =>
|
||||||
|
apiRequest<{ trip: Trip }>(`/api/trips/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-summary", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteTrip() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ success: boolean }>(`/api/trips/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompleteTrip() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ trip: Trip }>(`/api/trips/${id}/complete`, { method: "POST" }),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-summary", id] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-settlement", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trip Expense Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateTripExpense(tripId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateTripExpenseInput) =>
|
||||||
|
apiRequest<{ expense: TripExpense }>(`/api/trips/${tripId}/expenses`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-expenses", tripId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-summary", tripId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteTripExpense(tripId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (expenseId: string) =>
|
||||||
|
apiRequest<{ success: boolean }>(`/api/trips/${tripId}/expenses/${expenseId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-expenses", tripId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-summary", tripId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTripExpense(tripId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ expenseId, input }: { expenseId: string; input: UpdateTripExpenseInput }) =>
|
||||||
|
apiRequest<{ expense: TripExpense }>(`/api/trips/${tripId}/expenses/${expenseId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-expenses", tripId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trip-summary", tripId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
18
apps/native/src/i18n/index.ts
Normal file
18
apps/native/src/i18n/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
import * as Localization from 'expo-localization'
|
||||||
|
import de from './locales/de.json'
|
||||||
|
import en from './locales/en.json'
|
||||||
|
|
||||||
|
const deviceLanguage = Localization.getLocales()[0]?.languageCode ?? 'de'
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: { de: { translation: de }, en: { translation: en } },
|
||||||
|
lng: deviceLanguage,
|
||||||
|
fallbackLng: 'de',
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n
|
||||||
443
apps/native/src/i18n/locales/de.json
Normal file
443
apps/native/src/i18n/locales/de.json
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"close": "Schließen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"loading": "Lädt...",
|
||||||
|
"error": "Fehler",
|
||||||
|
"confirm": "Bestätigen",
|
||||||
|
"currency": "€",
|
||||||
|
"next": "Weiter",
|
||||||
|
"create": "Erstellen",
|
||||||
|
"book": "Buchen",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein",
|
||||||
|
"or": "oder",
|
||||||
|
"new": "Neu",
|
||||||
|
"today": "Heute",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"notice": "Hinweis",
|
||||||
|
"monthly": "monatlich",
|
||||||
|
"select": "Wählen…"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"household": "Haushalt",
|
||||||
|
"me": "Ich",
|
||||||
|
"children": "Kinder",
|
||||||
|
"shopping": "Einkauf",
|
||||||
|
"more": "Mehr"
|
||||||
|
},
|
||||||
|
"mehr": {
|
||||||
|
"vacation": "Urlaub",
|
||||||
|
"vacationSubtitle": "Reisebudget & Ausgaben",
|
||||||
|
"settingsSubtitle": "Fixkosten, Kategorien, Haushalt"
|
||||||
|
},
|
||||||
|
"household": {
|
||||||
|
"title": "Haushalt",
|
||||||
|
"income": "Einnahmen",
|
||||||
|
"expenses": "Ausgaben",
|
||||||
|
"balance": "Bilanz",
|
||||||
|
"all": "Alle",
|
||||||
|
"noTransactions": "Noch keine Buchungen",
|
||||||
|
"noTransactionsHint": "Tippe auf + um eine gemeinsame Ausgabe einzutragen",
|
||||||
|
"nettoMonth": "Netto Monat",
|
||||||
|
"settlement": {
|
||||||
|
"youOwe": "Du schuldest {{name}}",
|
||||||
|
"theyOwe": "{{name}} schuldet dir",
|
||||||
|
"allSettled": "✓ Alles beglichen",
|
||||||
|
"book": "Buchen",
|
||||||
|
"alreadyTransferred": "Bereits überwiesen",
|
||||||
|
"monthlySettlement": "Monatsabrechnung",
|
||||||
|
"householdExpenses": "Haushalt Ausgaben",
|
||||||
|
"householdIncome": "Haushalt Einnahmen",
|
||||||
|
"yourShare": "Dein Anteil ({{percent}}%)",
|
||||||
|
"paidBy": "{{name}} gezahlt",
|
||||||
|
"fixedTransfers": "Feste Überweisungen",
|
||||||
|
"toTransfer": "Zu überweisen",
|
||||||
|
"closeMonth": "Monat abschließen",
|
||||||
|
"closed": "Abgeschlossen",
|
||||||
|
"recordTransfer": "Überweisung buchen",
|
||||||
|
"transferAmount": "Überwiesener Betrag",
|
||||||
|
"notePlaceholder": "Notiz (optional)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"me": {
|
||||||
|
"title": "Ich",
|
||||||
|
"noTransactions": "Noch keine privaten Buchungen",
|
||||||
|
"noTransactionsHint": "Nur du siehst diese Einträge — niemand sonst im Haushalt"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"title": "Kinder",
|
||||||
|
"addChild": "Kind hinzufügen",
|
||||||
|
"noChildren": "Noch keine Kinder angelegt",
|
||||||
|
"noChildrenHint": "Lege ein Kind an, um Ausgaben separat zu verfolgen.",
|
||||||
|
"noTransactions": "Noch keine Buchungen für {{name}}",
|
||||||
|
"noTransactionsHint": "Tippe auf + um die erste Buchung einzutragen"
|
||||||
|
},
|
||||||
|
"shopping": {
|
||||||
|
"title": "Einkaufsliste",
|
||||||
|
"empty": "Noch nichts auf der Liste",
|
||||||
|
"emptyHint": "Füge dein erstes Produkt unten hinzu",
|
||||||
|
"placeholder": "Produkt hinzufügen…",
|
||||||
|
"deleteChecked": "Erledigt löschen",
|
||||||
|
"offline": "offline",
|
||||||
|
"checkedBy": "von {{name}}",
|
||||||
|
"deleteCheckedConfirm": "Alle erledigten Items löschen?",
|
||||||
|
"reconnecting": "Verbindung wird hergestellt…",
|
||||||
|
"quantityPlaceholder": "Menge (optional)"
|
||||||
|
},
|
||||||
|
"debts": {
|
||||||
|
"title": "Schulden & Kredite",
|
||||||
|
"new": "+ Neu",
|
||||||
|
"open": "{{amount}} offen",
|
||||||
|
"payRate": "Rate buchen",
|
||||||
|
"noDebts": "Keine offenen Schulden",
|
||||||
|
"rateAutoBooked": "Diese Rate wird automatisch als private Ausgabe gebucht.",
|
||||||
|
"addTitle": "Schuld erfassen",
|
||||||
|
"totalAmount": "Gesamtbetrag",
|
||||||
|
"labelRequired": "Bezeichnung *",
|
||||||
|
"labelPlaceholder": "z.B. Autokredit",
|
||||||
|
"iOweMoneyTo": "Ich schulde das Geld…",
|
||||||
|
"selectMember": "Haushaltsmitglied wählen",
|
||||||
|
"orEnterName": "…oder Name eingeben (z.B. Sparkasse)",
|
||||||
|
"noteOptional": "Notiz (optional)",
|
||||||
|
"notePlaceholder": "z.B. Laufzeit bis 2026",
|
||||||
|
"remaining": "Noch offen: {{amount}}",
|
||||||
|
"overpayingWarning": "Betrag übersteigt den offenen Restbetrag",
|
||||||
|
"paid": "Bezahlt",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"openAmount": "Noch offen",
|
||||||
|
"remainingLabel": "{{amount}} offen",
|
||||||
|
"toggleClosed_show": "{{count}} abgeschlossene{{plural}} anzeigen",
|
||||||
|
"toggleClosed_hide": "{{count}} abgeschlossene{{plural}} ausblenden",
|
||||||
|
"claims": "Offene Forderungen",
|
||||||
|
"received": "Erhalten",
|
||||||
|
"pendingLabel": "Ausstehend",
|
||||||
|
"fullyRepaid": "Vollständig zurückgezahlt ✓",
|
||||||
|
"noDebtsEntered": "Keine Schulden eingetragen.",
|
||||||
|
"fromDebtor": "von {{name}} · {{amount}} offen",
|
||||||
|
"unknown": "Unbekannt"
|
||||||
|
},
|
||||||
|
"fixedCosts": {
|
||||||
|
"title": "Fixkosten",
|
||||||
|
"household": "Haushalt",
|
||||||
|
"me": "Ich",
|
||||||
|
"children": "Kinder",
|
||||||
|
"expense": "Ausgabe · monatlich",
|
||||||
|
"income": "Einnahme · monatlich",
|
||||||
|
"noItems": "Keine Fixkosten eingetragen",
|
||||||
|
"editTitle": "Fixkosten bearbeiten",
|
||||||
|
"addTitle": "Neue Fixkosten",
|
||||||
|
"labelRequired": "Bezeichnung *",
|
||||||
|
"labelPlaceholder": "z.B. Miete",
|
||||||
|
"categoryOptional": "Kategorie (optional)",
|
||||||
|
"pauseTitle": "Fixkosten pausieren",
|
||||||
|
"pauseMessage": "\"{{label}}\" wird pausiert und nicht mehr monatlich gebucht.",
|
||||||
|
"pause": "Pausieren",
|
||||||
|
"expenseType": "Ausgabe",
|
||||||
|
"incomeType": "Einnahme"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Einstellungen",
|
||||||
|
"householdPartner": "Haushalt & Partner",
|
||||||
|
"fixedCosts": "Fixkosten verwalten",
|
||||||
|
"transferItems": "Feste Überweisungsposten",
|
||||||
|
"categories": "Kategorien",
|
||||||
|
"language": "Sprache",
|
||||||
|
"languageAuto": "Automatisch (Gerätesprache)",
|
||||||
|
"languageDe": "Deutsch",
|
||||||
|
"languageEn": "English",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"members": "Mitglieder",
|
||||||
|
"pending": "Ausstehend",
|
||||||
|
"account": "Konto",
|
||||||
|
"households": "Haushalte",
|
||||||
|
"youSuffix": "(du)",
|
||||||
|
"invitePerson": "Person einladen",
|
||||||
|
"invite": "Einladen",
|
||||||
|
"emailLabel": "E-Mail-Adresse",
|
||||||
|
"emailPlaceholder": "person@beispiel.de",
|
||||||
|
"inviteHint": "Die Person erhält eine E-Mail mit einem Einladungslink.",
|
||||||
|
"inviteSent": "Einladung gesendet",
|
||||||
|
"inviteError": "Fehler beim Einladen",
|
||||||
|
"revokeTitle": "Einladung widerrufen",
|
||||||
|
"revokeMessage": "Einladung an {{email}} widerrufen?",
|
||||||
|
"revoke": "Widerrufen",
|
||||||
|
"revokeSuccess": "Einladung widerrufen",
|
||||||
|
"saveError": "Einstellungen konnten nicht gespeichert werden.",
|
||||||
|
"switchedTo": "Zu {{name}} gewechselt",
|
||||||
|
"appSection": "App",
|
||||||
|
"household": {
|
||||||
|
"title": "Haushalt & Partner",
|
||||||
|
"yourName": "Dein Name",
|
||||||
|
"partnerName": "Partner / Partnerin",
|
||||||
|
"sharePercent": "Dein Anteil",
|
||||||
|
"sharePreview": "Du zahlst {{own}}% · {{partner}} zahlt {{rest}}%",
|
||||||
|
"monthlyBudget": "Monatsbudget",
|
||||||
|
"splitChildren": "Kinderkosten teilen",
|
||||||
|
"currency": "Währung",
|
||||||
|
"namesSection": "Namen",
|
||||||
|
"payerSection": "Wer zahlt die Ausgaben vor?",
|
||||||
|
"payerHint": "Diese Person schießt alle Haushaltsausgaben vor. Der Partner überweist am Monatsende seinen Anteil.",
|
||||||
|
"costSplitSection": "Kostenaufteilung",
|
||||||
|
"costSplitHint": "Dein Anteil an gemeinsamen Haushaltskosten",
|
||||||
|
"settingsSection": "Einstellungen",
|
||||||
|
"currencyOnlyEur": "Derzeit wird nur EUR unterstützt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"monthClose": {
|
||||||
|
"title": "Monatsabschluss {{month}}",
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"householdTotal": "Haushalt gesamt",
|
||||||
|
"householdIncome": "Haushalt Einnahmen",
|
||||||
|
"yourShare": "Dein Anteil ({{percent}}%)",
|
||||||
|
"totalTransfer": "Gesamte Überweisung",
|
||||||
|
"alreadyTransferred": "Bereits überwiesen",
|
||||||
|
"receives": "{{name}} bekommt noch",
|
||||||
|
"youReceive": "Du bekommst noch",
|
||||||
|
"settled": "Alles ausgeglichen",
|
||||||
|
"adjustAmount": "Betrag anpassen (optional)",
|
||||||
|
"adjustHint": "Falls ihr euch auf einen gerundeten Betrag geeinigt habt.",
|
||||||
|
"note": "Notiz (optional)",
|
||||||
|
"notePlaceholder": "z.B. Abschluss März — per Dauerauftrag",
|
||||||
|
"closeButton": "Monat abschließen & sperren",
|
||||||
|
"closeConfirmTitle": "{{month}} abschließen?",
|
||||||
|
"closeConfirmMessage": "Dieser Monat wird gesperrt. Keine weiteren Buchungen oder Änderungen möglich.",
|
||||||
|
"closeConfirmAction": "Jetzt abschließen",
|
||||||
|
"closedBanner": "🔒 Abgeschlossen am {{date}}"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"welcome": "Willkommen bei HausApp",
|
||||||
|
"subtitle": "Deine persönliche Haushalts-App für gemeinsame Finanzen",
|
||||||
|
"start": "Los geht's",
|
||||||
|
"step": "Schritt {{current}} von {{total}}",
|
||||||
|
"yourName": "Wie heißt du?",
|
||||||
|
"yourNamePlaceholder": "Dein Name",
|
||||||
|
"partnerName": "Wie heißt dein Partner / deine Partnerin?",
|
||||||
|
"partnerNamePlaceholder": "Name des Partners",
|
||||||
|
"costSplit": "Wie viel zahlst du von den gemeinsamen Kosten?",
|
||||||
|
"preview": "Du zahlst {{own}}% · {{partner}} zahlt {{rest}}%",
|
||||||
|
"done": "✓ Alles eingerichtet!",
|
||||||
|
"doneHint": "Du kannst diese Einstellungen jederzeit unter Einstellungen → Haushalt ändern.",
|
||||||
|
"startApp": "App starten",
|
||||||
|
"skip": "Überspringen",
|
||||||
|
"createHousehold": "Haushalt erstellen",
|
||||||
|
"joinHousehold": "Einladungslink eingeben",
|
||||||
|
"setupTitle": "Haushalt einrichten",
|
||||||
|
"setupSubtitle": "Erstelle deinen Haushalt oder tritt einem bestehenden bei",
|
||||||
|
"householdNameLabel": "Haushaltsname",
|
||||||
|
"householdNamePlaceholder": "z.B. Familie Müller",
|
||||||
|
"enterHouseholdName": "Bitte einen Haushaltsnamen eingeben",
|
||||||
|
"createError": "Haushalt konnte nicht erstellt werden",
|
||||||
|
"enterInviteCode": "Bitte einen Einladungscode eingeben",
|
||||||
|
"invitesComingSoon": "Einladungen werden in Kürze unterstützt",
|
||||||
|
"inviteCodeLabel": "Einladungscode",
|
||||||
|
"inviteCodePlaceholder": "Einladungscode eingeben",
|
||||||
|
"joinHouseholdAction": "Einladung annehmen"
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"namesTitle": "Wie heißt ihr?",
|
||||||
|
"namesHint": "Diese Namen erscheinen in der Abrechnung und bei Schulden.",
|
||||||
|
"costSplitTitle": "Kostenaufteilung",
|
||||||
|
"costSplitHint": "Wie viel der gemeinsamen Haushaltskosten zahlst du?",
|
||||||
|
"monthlyBudgetLabel": "Gemeinsames Monatsbudget (variabel)",
|
||||||
|
"splitChildCostsLabel": "Kinderkosten gleich aufteilen?"
|
||||||
|
},
|
||||||
|
"vacation": {
|
||||||
|
"title": "Urlaub",
|
||||||
|
"comingSoon": "Bald verfügbar",
|
||||||
|
"comingSoonHint": "Urlaubsbudgets und Reiseausgaben — kommt in einer späteren Version."
|
||||||
|
},
|
||||||
|
"trips": {
|
||||||
|
"title": "Urlaub",
|
||||||
|
"new": "Neuer Urlaub",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"past": "Vergangen",
|
||||||
|
"budget": "Budget",
|
||||||
|
"spent": "Ausgegeben",
|
||||||
|
"remaining": "Verbleibend",
|
||||||
|
"noTrips": "Noch kein Urlaub geplant",
|
||||||
|
"noTripsHint": "Tippe auf + um einen Urlaub anzulegen",
|
||||||
|
"overBudget": "Budget überschritten um {{amount}}",
|
||||||
|
"paidBy": "Gezahlt von {{name}}",
|
||||||
|
"complete": "Abschließen",
|
||||||
|
"completed": "Abgeschlossen",
|
||||||
|
"destination": "Reiseziel",
|
||||||
|
"startDate": "Von",
|
||||||
|
"endDate": "Bis",
|
||||||
|
"name": "Name",
|
||||||
|
"newExpense": "Neue Ausgabe",
|
||||||
|
"label": "Bezeichnung",
|
||||||
|
"note": "Notiz (optional)",
|
||||||
|
"categories": {
|
||||||
|
"unterkunft": "Unterkunft",
|
||||||
|
"essen": "Essen",
|
||||||
|
"transport": "Transport",
|
||||||
|
"aktivitaeten": "Aktivitäten",
|
||||||
|
"sonstiges": "Sonstiges"
|
||||||
|
},
|
||||||
|
"settlement": {
|
||||||
|
"title": "Abrechnung",
|
||||||
|
"total": "Gesamtausgaben",
|
||||||
|
"fairShare": "Fairer Anteil (50%)",
|
||||||
|
"paid": "gezahlt",
|
||||||
|
"owes": "{{from}} schuldet {{to}}",
|
||||||
|
"balanced": "Ausgeglichen — niemand schuldet was",
|
||||||
|
"closeTrip": "Urlaub abschließen",
|
||||||
|
"closedBanner": "Abgeschlossen",
|
||||||
|
"settledInfo": "{{from}} hat {{to}} {{amount}} überwiesen",
|
||||||
|
"noExpenses": "Füge zuerst Ausgaben hinzu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"welcome": "Willkommen zurück",
|
||||||
|
"subtitle": "Melde dich in deinem Konto an",
|
||||||
|
"fillAllFields": "Bitte alle Felder ausfüllen",
|
||||||
|
"signInError": "Anmeldung fehlgeschlagen",
|
||||||
|
"emailLabel": "E-Mail",
|
||||||
|
"emailPlaceholder": "deine@email.de",
|
||||||
|
"passwordLabel": "Passwort",
|
||||||
|
"passwordPlaceholder": "••••••••",
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"signInWithApple": "Mit Apple anmelden",
|
||||||
|
"noAccount": "Noch kein Konto?",
|
||||||
|
"register": "Registrieren",
|
||||||
|
"forgotPassword": "Passwort vergessen?",
|
||||||
|
"appleSignInError": "Apple-Anmeldung fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"forgotPassword": {
|
||||||
|
"title": "Passwort vergessen?",
|
||||||
|
"subtitle": "Wir schicken dir einen Link zum Zurücksetzen deines Passworts.",
|
||||||
|
"sendButton": "Link senden",
|
||||||
|
"sentTitle": "E-Mail verschickt!",
|
||||||
|
"sentHint": "Check deine E-Mails — wir haben dir einen Link geschickt."
|
||||||
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"title": "Neues Passwort",
|
||||||
|
"subtitle": "Wähle ein sicheres Passwort mit mindestens 8 Zeichen.",
|
||||||
|
"newPassword": "Neues Passwort",
|
||||||
|
"confirmPassword": "Passwort bestätigen",
|
||||||
|
"mismatch": "Passwörter stimmen nicht überein",
|
||||||
|
"saveButton": "Passwort speichern",
|
||||||
|
"successMessage": "✓ Passwort geändert — du wirst weitergeleitet."
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"editTitle": "Kategorie bearbeiten",
|
||||||
|
"addTitle": "Neue Kategorie",
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"namePlaceholder": "z.B. Fitnessstudio",
|
||||||
|
"colorLabel": "Farbe",
|
||||||
|
"iconLabel": "Icon",
|
||||||
|
"selectIcon": "Icon wählen",
|
||||||
|
"defaultWarning": "Standardkategorien können umbenannt, aber nicht gelöscht werden.",
|
||||||
|
"default": "Standard",
|
||||||
|
"deleteTitle": "Kategorie löschen",
|
||||||
|
"deleteMessage": "\"{{name}}\" wirklich löschen?",
|
||||||
|
"addExpenseCategory": "+ Ausgaben-Kategorie hinzufügen",
|
||||||
|
"addIncomeCategory": "+ Einnahmen-Kategorie hinzufügen",
|
||||||
|
"expenseSection": "Ausgaben",
|
||||||
|
"incomeSection": "Einnahmen",
|
||||||
|
"expenseType": "Ausgabe",
|
||||||
|
"incomeType": "Einnahme"
|
||||||
|
},
|
||||||
|
"transferItems": {
|
||||||
|
"title": "Feste Überweisungsposten",
|
||||||
|
"addTitle": "Neuer Posten",
|
||||||
|
"monthlyFixedAmount": "Monatlicher Fixbetrag",
|
||||||
|
"labelRequired": "Bezeichnung *",
|
||||||
|
"labelPlaceholder": "z.B. Bausparer Noah",
|
||||||
|
"hint": "Diese Posten werden monatlich zur Haushaltsabrechnung addiert (z.B. Bausparer, Handy).",
|
||||||
|
"removeTitle": "Posten entfernen",
|
||||||
|
"removeMessage": "\"{{label}}\" wird aus der monatlichen Abrechnung entfernt.",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"empty": "Noch keine festen Posten eingetragen.",
|
||||||
|
"totalMonthly": "Gesamt monatlich",
|
||||||
|
"new": "Neu"
|
||||||
|
},
|
||||||
|
"carryOver": {
|
||||||
|
"title": "Saldo übertragen",
|
||||||
|
"confirmMessage": "Saldo von {{balance}} als {{type}} in {{month}} übertragen?",
|
||||||
|
"transfer": "Übertragen",
|
||||||
|
"openBalance": "{{month}} — offener Saldo",
|
||||||
|
"transferring": "Wird übertragen…",
|
||||||
|
"transferButton": "Saldo in {{month}} übertragen",
|
||||||
|
"expense": "Ausgabe",
|
||||||
|
"income": "Einnahme"
|
||||||
|
},
|
||||||
|
"scanner": {
|
||||||
|
"title": "Bon scannen",
|
||||||
|
"scanReceipt": "Bon scannen",
|
||||||
|
"manualEntry": "Manuelle Eingabe",
|
||||||
|
"hint": "Kassenbon in den Rahmen halten",
|
||||||
|
"capture": "Foto aufnehmen",
|
||||||
|
"scanning": "Wird erkannt...",
|
||||||
|
"detected": "Erkannt ✓",
|
||||||
|
"retry": "Nochmal scannen",
|
||||||
|
"book": "Buchen",
|
||||||
|
"permissionDenied": "Kamera-Zugriff verweigert. Bitte in den Einstellungen aktivieren.",
|
||||||
|
"openSettings": "Einstellungen öffnen",
|
||||||
|
"notRecognized": "Betrag konnte nicht erkannt werden.",
|
||||||
|
"merchant": "Händler",
|
||||||
|
"amount": "Betrag",
|
||||||
|
"date": "Datum",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"scope": "Bereich",
|
||||||
|
"household": "Haushalt",
|
||||||
|
"private": "Privat",
|
||||||
|
"error": "Fehler beim Scannen. Bitte erneut versuchen."
|
||||||
|
},
|
||||||
|
"invite": {
|
||||||
|
"title": "Person einladen",
|
||||||
|
"shareText": "Ich lade dich zu HausApp ein! Gib diesen Code in der App ein: {{code}} (gültig 24h)",
|
||||||
|
"validFor": "Gültig für 24 Stunden",
|
||||||
|
"copyCode": "Code kopieren",
|
||||||
|
"copied": "Kopiert!",
|
||||||
|
"share": "Teilen",
|
||||||
|
"newCode": "Neuen Code generieren",
|
||||||
|
"joinTitle": "Einladungscode",
|
||||||
|
"joinHint": "Gib den 6-stelligen Code ein den du erhalten hast:",
|
||||||
|
"joinButton": "Haushalt beitreten",
|
||||||
|
"invalidCode": "Ungültiger oder abgelaufener Code",
|
||||||
|
"alreadyMember": "Du bist bereits Mitglied dieses Haushalts",
|
||||||
|
"success": "Willkommen im Haushalt!",
|
||||||
|
"setupTitle": "Haushalt einrichten",
|
||||||
|
"createNew": "Neuen Haushalt erstellen",
|
||||||
|
"createNewSub": "Du richtest alles ein",
|
||||||
|
"enterCode": "Einladungscode eingeben",
|
||||||
|
"enterCodeSub": "Du wurdest eingeladen",
|
||||||
|
"generating": "Wird generiert..."
|
||||||
|
},
|
||||||
|
"transaction": {
|
||||||
|
"booking": "Buchung",
|
||||||
|
"bookingType": "Buchungstyp",
|
||||||
|
"expense": "Ausgabe",
|
||||||
|
"income": "Einnahme",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"date": "Datum",
|
||||||
|
"deleteTitle": "Buchung löschen?",
|
||||||
|
"deleteMessage": "Diese Buchung wird unwiderruflich gelöscht.",
|
||||||
|
"deleteFixed": "Diese Fixkostenbuchung wird nur für diesen Monat gelöscht. Im nächsten Monat wird sie wieder automatisch erstellt.",
|
||||||
|
"deleteDebt": "Diese Rate wird auch aus Schulden & Kredite entfernt.",
|
||||||
|
"fixedWarning": "Das ist eine Fixkostenbuchung. Änderungen gelten nur für diesen Monat.",
|
||||||
|
"carryOver": "Übertrag",
|
||||||
|
"newBooking": "Neue Buchung",
|
||||||
|
"editTitle": "Buchung bearbeiten",
|
||||||
|
"selectCategory": "Kategorie wählen (optional)",
|
||||||
|
"descriptionOptional": "Beschreibung (optional)",
|
||||||
|
"repeatMonthly": "Jeden Monat wiederholen",
|
||||||
|
"addNewCategory": "Neue Kategorie anlegen"
|
||||||
|
},
|
||||||
|
"verifyEmail": {
|
||||||
|
"title": "E-Mail bestätigen",
|
||||||
|
"hint": "Wir haben dir eine Bestätigungs-E-Mail geschickt an:",
|
||||||
|
"resend": "E-Mail erneut senden",
|
||||||
|
"resentConfirm": "✓ E-Mail wurde erneut gesendet",
|
||||||
|
"resendError": "Fehler beim Senden der E-Mail",
|
||||||
|
"backToLogin": "Zurück zur Anmeldung"
|
||||||
|
}
|
||||||
|
}
|
||||||
443
apps/native/src/i18n/locales/en.json
Normal file
443
apps/native/src/i18n/locales/en.json
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"add": "Add",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"currency": "€",
|
||||||
|
"next": "Next",
|
||||||
|
"create": "Create",
|
||||||
|
"book": "Book",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"or": "or",
|
||||||
|
"new": "New",
|
||||||
|
"today": "Today",
|
||||||
|
"preview": "Preview",
|
||||||
|
"notice": "Notice",
|
||||||
|
"monthly": "monthly",
|
||||||
|
"select": "Select…"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"household": "Household",
|
||||||
|
"me": "Me",
|
||||||
|
"children": "Kids",
|
||||||
|
"shopping": "Shopping",
|
||||||
|
"more": "More"
|
||||||
|
},
|
||||||
|
"mehr": {
|
||||||
|
"vacation": "Vacation",
|
||||||
|
"vacationSubtitle": "Travel budget & expenses",
|
||||||
|
"settingsSubtitle": "Fixed costs, categories, household"
|
||||||
|
},
|
||||||
|
"household": {
|
||||||
|
"title": "Household",
|
||||||
|
"income": "Income",
|
||||||
|
"expenses": "Expenses",
|
||||||
|
"balance": "Balance",
|
||||||
|
"all": "All",
|
||||||
|
"noTransactions": "No transactions yet",
|
||||||
|
"noTransactionsHint": "Tap + to add a shared expense",
|
||||||
|
"nettoMonth": "Net Month",
|
||||||
|
"settlement": {
|
||||||
|
"youOwe": "You owe {{name}}",
|
||||||
|
"theyOwe": "{{name}} owes you",
|
||||||
|
"allSettled": "✓ All settled",
|
||||||
|
"book": "Book",
|
||||||
|
"alreadyTransferred": "Already transferred",
|
||||||
|
"monthlySettlement": "Monthly settlement",
|
||||||
|
"householdExpenses": "Household expenses",
|
||||||
|
"householdIncome": "Household income",
|
||||||
|
"yourShare": "Your share ({{percent}}%)",
|
||||||
|
"paidBy": "{{name}} paid",
|
||||||
|
"fixedTransfers": "Fixed transfers",
|
||||||
|
"toTransfer": "To transfer",
|
||||||
|
"closeMonth": "Close month",
|
||||||
|
"closed": "Closed",
|
||||||
|
"recordTransfer": "Record transfer",
|
||||||
|
"transferAmount": "Transfer amount",
|
||||||
|
"notePlaceholder": "Note (optional)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"me": {
|
||||||
|
"title": "Me",
|
||||||
|
"noTransactions": "No private transactions yet",
|
||||||
|
"noTransactionsHint": "Only you can see these — nobody else in the household"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"title": "Kids",
|
||||||
|
"addChild": "Add child",
|
||||||
|
"noChildren": "No children added yet",
|
||||||
|
"noChildrenHint": "Add a child to track expenses separately.",
|
||||||
|
"noTransactions": "No transactions for {{name}} yet",
|
||||||
|
"noTransactionsHint": "Tap + to add the first transaction"
|
||||||
|
},
|
||||||
|
"shopping": {
|
||||||
|
"title": "Shopping List",
|
||||||
|
"empty": "Nothing on the list yet",
|
||||||
|
"emptyHint": "Add your first item below",
|
||||||
|
"placeholder": "Add item…",
|
||||||
|
"deleteChecked": "Delete checked",
|
||||||
|
"offline": "offline",
|
||||||
|
"checkedBy": "by {{name}}",
|
||||||
|
"deleteCheckedConfirm": "Delete all checked items?",
|
||||||
|
"reconnecting": "Reconnecting…",
|
||||||
|
"quantityPlaceholder": "Quantity (optional)"
|
||||||
|
},
|
||||||
|
"debts": {
|
||||||
|
"title": "Debts & Loans",
|
||||||
|
"new": "+ New",
|
||||||
|
"open": "{{amount}} remaining",
|
||||||
|
"payRate": "Book payment",
|
||||||
|
"noDebts": "No open debts",
|
||||||
|
"rateAutoBooked": "This payment will automatically be booked as a private expense.",
|
||||||
|
"addTitle": "Record debt",
|
||||||
|
"totalAmount": "Total amount",
|
||||||
|
"labelRequired": "Label *",
|
||||||
|
"labelPlaceholder": "e.g. Car loan",
|
||||||
|
"iOweMoneyTo": "I owe the money to…",
|
||||||
|
"selectMember": "Select household member",
|
||||||
|
"orEnterName": "…or enter name (e.g. Bank)",
|
||||||
|
"noteOptional": "Note (optional)",
|
||||||
|
"notePlaceholder": "e.g. Term until 2026",
|
||||||
|
"remaining": "Remaining: {{amount}}",
|
||||||
|
"overpayingWarning": "Amount exceeds the open remaining balance",
|
||||||
|
"paid": "Paid",
|
||||||
|
"total": "Total",
|
||||||
|
"openAmount": "Remaining",
|
||||||
|
"remainingLabel": "{{amount}} remaining",
|
||||||
|
"toggleClosed_show": "Show {{count}} closed",
|
||||||
|
"toggleClosed_hide": "Hide {{count}} closed",
|
||||||
|
"claims": "Open claims",
|
||||||
|
"received": "Received",
|
||||||
|
"pendingLabel": "Pending",
|
||||||
|
"fullyRepaid": "Fully repaid ✓",
|
||||||
|
"noDebtsEntered": "No debts recorded.",
|
||||||
|
"fromDebtor": "from {{name}} · {{amount}} remaining",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"fixedCosts": {
|
||||||
|
"title": "Fixed Costs",
|
||||||
|
"household": "Household",
|
||||||
|
"me": "Me",
|
||||||
|
"children": "Kids",
|
||||||
|
"expense": "Expense · monthly",
|
||||||
|
"income": "Income · monthly",
|
||||||
|
"noItems": "No fixed costs added",
|
||||||
|
"editTitle": "Edit fixed cost",
|
||||||
|
"addTitle": "New fixed cost",
|
||||||
|
"labelRequired": "Label *",
|
||||||
|
"labelPlaceholder": "e.g. Rent",
|
||||||
|
"categoryOptional": "Category (optional)",
|
||||||
|
"pauseTitle": "Pause fixed cost",
|
||||||
|
"pauseMessage": "\"{{label}}\" will be paused and no longer booked monthly.",
|
||||||
|
"pause": "Pause",
|
||||||
|
"expenseType": "Expense",
|
||||||
|
"incomeType": "Income"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"householdPartner": "Household & Partner",
|
||||||
|
"fixedCosts": "Manage fixed costs",
|
||||||
|
"transferItems": "Fixed transfer items",
|
||||||
|
"categories": "Categories",
|
||||||
|
"language": "Language",
|
||||||
|
"languageAuto": "Automatic (device language)",
|
||||||
|
"languageDe": "Deutsch",
|
||||||
|
"languageEn": "English",
|
||||||
|
"logout": "Sign out",
|
||||||
|
"members": "Members",
|
||||||
|
"pending": "Pending",
|
||||||
|
"account": "Account",
|
||||||
|
"households": "Households",
|
||||||
|
"youSuffix": "(you)",
|
||||||
|
"invitePerson": "Invite person",
|
||||||
|
"invite": "Invite",
|
||||||
|
"emailLabel": "Email address",
|
||||||
|
"emailPlaceholder": "person@example.com",
|
||||||
|
"inviteHint": "The person will receive an email with an invitation link.",
|
||||||
|
"inviteSent": "Invitation sent",
|
||||||
|
"inviteError": "Error sending invitation",
|
||||||
|
"revokeTitle": "Revoke invitation",
|
||||||
|
"revokeMessage": "Revoke invitation for {{email}}?",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revokeSuccess": "Invitation revoked",
|
||||||
|
"saveError": "Could not save settings.",
|
||||||
|
"switchedTo": "Switched to {{name}}",
|
||||||
|
"appSection": "App",
|
||||||
|
"household": {
|
||||||
|
"title": "Household & Partner",
|
||||||
|
"yourName": "Your name",
|
||||||
|
"partnerName": "Partner",
|
||||||
|
"sharePercent": "Your share",
|
||||||
|
"sharePreview": "You pay {{own}}% · {{partner}} pays {{rest}}%",
|
||||||
|
"monthlyBudget": "Monthly budget",
|
||||||
|
"splitChildren": "Split child costs",
|
||||||
|
"currency": "Currency",
|
||||||
|
"namesSection": "Names",
|
||||||
|
"payerSection": "Who pays expenses upfront?",
|
||||||
|
"payerHint": "This person pays all household expenses. The partner transfers their share at month end.",
|
||||||
|
"costSplitSection": "Cost split",
|
||||||
|
"costSplitHint": "Your share of shared household costs",
|
||||||
|
"settingsSection": "Settings",
|
||||||
|
"currencyOnlyEur": "Currently only EUR is supported."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"monthClose": {
|
||||||
|
"title": "Month close {{month}}",
|
||||||
|
"overview": "Overview",
|
||||||
|
"householdTotal": "Household total",
|
||||||
|
"householdIncome": "Household income",
|
||||||
|
"yourShare": "Your share ({{percent}}%)",
|
||||||
|
"totalTransfer": "Total transfer",
|
||||||
|
"alreadyTransferred": "Already transferred",
|
||||||
|
"receives": "{{name}} receives",
|
||||||
|
"youReceive": "You receive",
|
||||||
|
"settled": "All settled",
|
||||||
|
"adjustAmount": "Adjust amount (optional)",
|
||||||
|
"adjustHint": "In case you agreed on a rounded amount.",
|
||||||
|
"note": "Note (optional)",
|
||||||
|
"notePlaceholder": "e.g. March close — standing order",
|
||||||
|
"closeButton": "Close & lock month",
|
||||||
|
"closeConfirmTitle": "Close {{month}}?",
|
||||||
|
"closeConfirmMessage": "This month will be locked. No further bookings or changes possible.",
|
||||||
|
"closeConfirmAction": "Close now",
|
||||||
|
"closedBanner": "🔒 Closed on {{date}}"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"welcome": "Welcome to HausApp",
|
||||||
|
"subtitle": "Your personal household app for shared finances",
|
||||||
|
"start": "Get started",
|
||||||
|
"step": "Step {{current}} of {{total}}",
|
||||||
|
"yourName": "What's your name?",
|
||||||
|
"yourNamePlaceholder": "Your name",
|
||||||
|
"partnerName": "What's your partner's name?",
|
||||||
|
"partnerNamePlaceholder": "Partner's name",
|
||||||
|
"costSplit": "How much of the shared costs do you pay?",
|
||||||
|
"preview": "You pay {{own}}% · {{partner}} pays {{rest}}%",
|
||||||
|
"done": "✓ All set up!",
|
||||||
|
"doneHint": "You can change these settings anytime under Settings → Household.",
|
||||||
|
"startApp": "Start app",
|
||||||
|
"skip": "Skip",
|
||||||
|
"createHousehold": "Create household",
|
||||||
|
"joinHousehold": "Enter invitation link",
|
||||||
|
"setupTitle": "Set up household",
|
||||||
|
"setupSubtitle": "Create your household or join an existing one",
|
||||||
|
"householdNameLabel": "Household name",
|
||||||
|
"householdNamePlaceholder": "e.g. Smith Family",
|
||||||
|
"enterHouseholdName": "Please enter a household name",
|
||||||
|
"createError": "Could not create household",
|
||||||
|
"enterInviteCode": "Please enter an invitation code",
|
||||||
|
"invitesComingSoon": "Invitations will be supported soon",
|
||||||
|
"inviteCodeLabel": "Invitation code",
|
||||||
|
"inviteCodePlaceholder": "Enter invitation code",
|
||||||
|
"joinHouseholdAction": "Accept invitation"
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"namesTitle": "What are your names?",
|
||||||
|
"namesHint": "These names appear in statements and debts.",
|
||||||
|
"costSplitTitle": "Cost split",
|
||||||
|
"costSplitHint": "How much of the shared household costs do you pay?",
|
||||||
|
"monthlyBudgetLabel": "Shared monthly budget (variable)",
|
||||||
|
"splitChildCostsLabel": "Split child costs equally?"
|
||||||
|
},
|
||||||
|
"vacation": {
|
||||||
|
"title": "Vacation",
|
||||||
|
"comingSoon": "Coming soon",
|
||||||
|
"comingSoonHint": "Vacation budgets and travel expenses — coming in a later version."
|
||||||
|
},
|
||||||
|
"trips": {
|
||||||
|
"title": "Vacation",
|
||||||
|
"new": "New trip",
|
||||||
|
"active": "Active",
|
||||||
|
"past": "Past",
|
||||||
|
"budget": "Budget",
|
||||||
|
"spent": "Spent",
|
||||||
|
"remaining": "Remaining",
|
||||||
|
"noTrips": "No trips planned yet",
|
||||||
|
"noTripsHint": "Tap + to add a trip",
|
||||||
|
"overBudget": "Over budget by {{amount}}",
|
||||||
|
"paidBy": "Paid by {{name}}",
|
||||||
|
"complete": "Complete",
|
||||||
|
"completed": "Completed",
|
||||||
|
"destination": "Destination",
|
||||||
|
"startDate": "From",
|
||||||
|
"endDate": "To",
|
||||||
|
"name": "Name",
|
||||||
|
"newExpense": "New expense",
|
||||||
|
"label": "Label",
|
||||||
|
"note": "Note (optional)",
|
||||||
|
"categories": {
|
||||||
|
"unterkunft": "Accommodation",
|
||||||
|
"essen": "Food",
|
||||||
|
"transport": "Transport",
|
||||||
|
"aktivitaeten": "Activities",
|
||||||
|
"sonstiges": "Other"
|
||||||
|
},
|
||||||
|
"settlement": {
|
||||||
|
"title": "Settlement",
|
||||||
|
"total": "Total expenses",
|
||||||
|
"fairShare": "Fair share (50%)",
|
||||||
|
"paid": "paid",
|
||||||
|
"owes": "{{from}} owes {{to}}",
|
||||||
|
"balanced": "Balanced — nobody owes anything",
|
||||||
|
"closeTrip": "Complete trip",
|
||||||
|
"closedBanner": "Completed",
|
||||||
|
"settledInfo": "{{from}} transferred {{amount}} to {{to}}",
|
||||||
|
"noExpenses": "Add expenses first"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"welcome": "Welcome back",
|
||||||
|
"subtitle": "Sign in to your account",
|
||||||
|
"fillAllFields": "Please fill in all fields",
|
||||||
|
"signInError": "Sign in failed",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "your@email.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "••••••••",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"signInWithApple": "Sign in with Apple",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"register": "Register",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"appleSignInError": "Apple sign-in failed"
|
||||||
|
},
|
||||||
|
"forgotPassword": {
|
||||||
|
"title": "Forgot password?",
|
||||||
|
"subtitle": "We'll send you a link to reset your password.",
|
||||||
|
"sendButton": "Send link",
|
||||||
|
"sentTitle": "Email sent!",
|
||||||
|
"sentHint": "Check your emails — we sent you a reset link."
|
||||||
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"title": "New password",
|
||||||
|
"subtitle": "Choose a secure password with at least 8 characters.",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"mismatch": "Passwords don't match",
|
||||||
|
"saveButton": "Save password",
|
||||||
|
"successMessage": "✓ Password changed — redirecting you now."
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"editTitle": "Edit category",
|
||||||
|
"addTitle": "New category",
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"namePlaceholder": "e.g. Gym",
|
||||||
|
"colorLabel": "Color",
|
||||||
|
"iconLabel": "Icon",
|
||||||
|
"selectIcon": "Select icon",
|
||||||
|
"defaultWarning": "Default categories can be renamed but not deleted.",
|
||||||
|
"default": "Default",
|
||||||
|
"deleteTitle": "Delete category",
|
||||||
|
"deleteMessage": "Really delete \"{{name}}\"?",
|
||||||
|
"addExpenseCategory": "+ Add expense category",
|
||||||
|
"addIncomeCategory": "+ Add income category",
|
||||||
|
"expenseSection": "Expenses",
|
||||||
|
"incomeSection": "Income",
|
||||||
|
"expenseType": "Expense",
|
||||||
|
"incomeType": "Income"
|
||||||
|
},
|
||||||
|
"transferItems": {
|
||||||
|
"title": "Fixed transfer items",
|
||||||
|
"addTitle": "New item",
|
||||||
|
"monthlyFixedAmount": "Monthly fixed amount",
|
||||||
|
"labelRequired": "Label *",
|
||||||
|
"labelPlaceholder": "e.g. Savings plan Noah",
|
||||||
|
"hint": "These items are added monthly to the household statement (e.g. savings plans, phone).",
|
||||||
|
"removeTitle": "Remove item",
|
||||||
|
"removeMessage": "\"{{label}}\" will be removed from the monthly statement.",
|
||||||
|
"remove": "Remove",
|
||||||
|
"empty": "No fixed items added yet.",
|
||||||
|
"totalMonthly": "Total monthly",
|
||||||
|
"new": "New"
|
||||||
|
},
|
||||||
|
"carryOver": {
|
||||||
|
"title": "Transfer balance",
|
||||||
|
"confirmMessage": "Transfer balance of {{balance}} as {{type}} to {{month}}?",
|
||||||
|
"transfer": "Transfer",
|
||||||
|
"openBalance": "{{month}} — open balance",
|
||||||
|
"transferring": "Transferring…",
|
||||||
|
"transferButton": "Transfer balance to {{month}}",
|
||||||
|
"expense": "expense",
|
||||||
|
"income": "income"
|
||||||
|
},
|
||||||
|
"scanner": {
|
||||||
|
"title": "Scan Receipt",
|
||||||
|
"scanReceipt": "Scan Receipt",
|
||||||
|
"manualEntry": "Manual Entry",
|
||||||
|
"hint": "Hold receipt in frame",
|
||||||
|
"capture": "Take Photo",
|
||||||
|
"scanning": "Recognizing...",
|
||||||
|
"detected": "Detected ✓",
|
||||||
|
"retry": "Scan Again",
|
||||||
|
"book": "Book",
|
||||||
|
"permissionDenied": "Camera access denied. Please enable in settings.",
|
||||||
|
"openSettings": "Open Settings",
|
||||||
|
"notRecognized": "Could not recognize amount.",
|
||||||
|
"merchant": "Merchant",
|
||||||
|
"amount": "Amount",
|
||||||
|
"date": "Date",
|
||||||
|
"category": "Category",
|
||||||
|
"scope": "Scope",
|
||||||
|
"household": "Household",
|
||||||
|
"private": "Private",
|
||||||
|
"error": "Scan failed. Please try again."
|
||||||
|
},
|
||||||
|
"invite": {
|
||||||
|
"title": "Invite Person",
|
||||||
|
"shareText": "I'm inviting you to HausApp! Enter this code in the app: {{code}} (valid 24h)",
|
||||||
|
"validFor": "Valid for 24 hours",
|
||||||
|
"copyCode": "Copy code",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"share": "Share",
|
||||||
|
"newCode": "Generate new code",
|
||||||
|
"joinTitle": "Invitation Code",
|
||||||
|
"joinHint": "Enter the 6-digit code you received:",
|
||||||
|
"joinButton": "Join Household",
|
||||||
|
"invalidCode": "Invalid or expired code",
|
||||||
|
"alreadyMember": "You are already a member of this household",
|
||||||
|
"success": "Welcome to the household!",
|
||||||
|
"setupTitle": "Set up household",
|
||||||
|
"createNew": "Create new household",
|
||||||
|
"createNewSub": "You set everything up",
|
||||||
|
"enterCode": "Enter invitation code",
|
||||||
|
"enterCodeSub": "You were invited",
|
||||||
|
"generating": "Generating..."
|
||||||
|
},
|
||||||
|
"transaction": {
|
||||||
|
"booking": "Booking",
|
||||||
|
"bookingType": "Type",
|
||||||
|
"expense": "Expense",
|
||||||
|
"income": "Income",
|
||||||
|
"category": "Category",
|
||||||
|
"description": "Description",
|
||||||
|
"date": "Date",
|
||||||
|
"deleteTitle": "Delete booking?",
|
||||||
|
"deleteMessage": "This booking will be permanently deleted.",
|
||||||
|
"deleteFixed": "This fixed cost booking will only be deleted for this month. It will be recreated automatically next month.",
|
||||||
|
"deleteDebt": "This payment will also be removed from Debts & Loans.",
|
||||||
|
"fixedWarning": "This is a fixed cost booking. Changes only apply to this month.",
|
||||||
|
"carryOver": "Carry over",
|
||||||
|
"newBooking": "New booking",
|
||||||
|
"editTitle": "Edit booking",
|
||||||
|
"selectCategory": "Select category (optional)",
|
||||||
|
"descriptionOptional": "Description (optional)",
|
||||||
|
"repeatMonthly": "Repeat every month",
|
||||||
|
"addNewCategory": "Add new category"
|
||||||
|
},
|
||||||
|
"verifyEmail": {
|
||||||
|
"title": "Verify your email",
|
||||||
|
"hint": "We sent a verification email to:",
|
||||||
|
"resend": "Resend email",
|
||||||
|
"resentConfirm": "✓ Email sent again",
|
||||||
|
"resendError": "Failed to send email",
|
||||||
|
"backToLogin": "Back to sign in"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/native/src/lib/api-client.ts
Normal file
41
apps/native/src/lib/api-client.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { env } from "@haushaltsApp/env/native";
|
||||||
|
import { useAuthStore } from "../stores/auth.store";
|
||||||
|
|
||||||
|
const BASE_URL = env.EXPO_PUBLIC_SERVER_URL;
|
||||||
|
// expoClient plugin stores session token under "<storagePrefix>.session_token"
|
||||||
|
const TOKEN_KEY = "haushaltsapp.session_token";
|
||||||
|
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const householdId = useAuthStore.getState().activeHouseholdId;
|
||||||
|
const token = await SecureStore.getItemAsync(TOKEN_KEY);
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(householdId ? { "x-household-id": householdId } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||||
|
useAuthStore.getState().clearSession();
|
||||||
|
router.replace("/(auth)/login");
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: "Unknown error" }));
|
||||||
|
throw new Error((error as { error: string }).error ?? `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
34
apps/native/src/lib/auth-client.ts
Normal file
34
apps/native/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { organizationClient } from "better-auth/client/plugins";
|
||||||
|
import { expoClient } from "@better-auth/expo/client";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { env } from "@haushaltsApp/env/native";
|
||||||
|
|
||||||
|
// expoClient plugin stores session token under "<storagePrefix>.session_token"
|
||||||
|
const TOKEN_KEY = "haushaltsapp.session_token";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: env.EXPO_PUBLIC_SERVER_URL,
|
||||||
|
fetchOptions: {
|
||||||
|
onSuccess: (ctx) => {
|
||||||
|
const token = ctx.response.headers.get("set-auth-token");
|
||||||
|
if (token) {
|
||||||
|
SecureStore.setItemAsync(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
type: "Bearer",
|
||||||
|
token: () => SecureStore.getItem(TOKEN_KEY) ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
expoClient({
|
||||||
|
scheme: "haushaltsApp",
|
||||||
|
storagePrefix: "haushaltsapp",
|
||||||
|
storage: SecureStore,
|
||||||
|
}),
|
||||||
|
organizationClient(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { signIn, signUp, signOut, useSession } = authClient;
|
||||||
10
apps/native/src/lib/query-client.ts
Normal file
10
apps/native/src/lib/query-client.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60, // 1 minute
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
47
apps/native/src/lib/ws-client.ts
Normal file
47
apps/native/src/lib/ws-client.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { env } from "@haushaltsApp/env/native";
|
||||||
|
|
||||||
|
const WS_BASE_URL = env.EXPO_PUBLIC_SERVER_URL.replace(/^http/, "ws");
|
||||||
|
|
||||||
|
export type WSEventHandler<T> = (data: T) => void;
|
||||||
|
|
||||||
|
export class WebSocketClient<T = unknown> {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private handlers: Map<string, WSEventHandler<T>[]> = new Map();
|
||||||
|
|
||||||
|
connect(path: string): void {
|
||||||
|
this.ws = new WebSocket(`${WS_BASE_URL}${path}`);
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data as string) as { type: string } & T;
|
||||||
|
const typeHandlers = this.handlers.get(data.type) ?? [];
|
||||||
|
for (const handler of typeHandlers) {
|
||||||
|
handler(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to parse WebSocket message");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, handler: WSEventHandler<T>): void {
|
||||||
|
const existing = this.handlers.get(event) ?? [];
|
||||||
|
this.handlers.set(event, [...existing, handler]);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: unknown): void {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = null;
|
||||||
|
this.handlers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
105
apps/native/src/stores/auth.store.ts
Normal file
105
apps/native/src/stores/auth.store.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { create, type StoreApi, type UseBoundStore } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Household = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthState = {
|
||||||
|
user: User | null;
|
||||||
|
activeHouseholdId: string | null;
|
||||||
|
households: Household[];
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
pendingInvitationId: string | null;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
setActiveHousehold: (id: string) => void;
|
||||||
|
setHouseholds: (households: Household[]) => void;
|
||||||
|
setPendingInvitationId: (id: string | null) => void;
|
||||||
|
clearAuth: () => void;
|
||||||
|
clearSession: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authStateCreator = (
|
||||||
|
set: (partial: Partial<AuthState>) => void,
|
||||||
|
): AuthState => ({
|
||||||
|
user: null,
|
||||||
|
activeHouseholdId: null,
|
||||||
|
households: [],
|
||||||
|
isAuthenticated: false,
|
||||||
|
pendingInvitationId: null,
|
||||||
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||||
|
setActiveHousehold: (activeHouseholdId) => set({ activeHouseholdId }),
|
||||||
|
setHouseholds: (households) => set({ households }),
|
||||||
|
setPendingInvitationId: (pendingInvitationId) => set({ pendingInvitationId }),
|
||||||
|
clearAuth: () =>
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
activeHouseholdId: null,
|
||||||
|
households: [],
|
||||||
|
isAuthenticated: false,
|
||||||
|
pendingInvitationId: null,
|
||||||
|
}),
|
||||||
|
clearSession: () =>
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
activeHouseholdId: null,
|
||||||
|
households: [],
|
||||||
|
isAuthenticated: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lazily import SecureStore so it's only resolved at runtime, not at module load.
|
||||||
|
// This keeps bun:test able to test authStateCreator without React Native internals.
|
||||||
|
async function getSecureStorage() {
|
||||||
|
const SecureStore = await import("expo-secure-store");
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => SecureStore.getItemAsync(key),
|
||||||
|
setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value),
|
||||||
|
removeItem: (key: string) => SecureStore.deleteItemAsync(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let _store: UseBoundStore<StoreApi<AuthState>> | null = null;
|
||||||
|
|
||||||
|
export async function getAuthStore() {
|
||||||
|
if (_store) return _store;
|
||||||
|
const storage = await getSecureStorage();
|
||||||
|
_store = create<AuthState>()(
|
||||||
|
persist(authStateCreator, {
|
||||||
|
name: "auth-store",
|
||||||
|
storage: createJSONStorage(() => storage),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return _store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous store for use in non-async contexts (components).
|
||||||
|
// Storage hydration happens async — initial state is always the default.
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(authStateCreator, {
|
||||||
|
name: "auth-store",
|
||||||
|
storage: createJSONStorage(() => ({
|
||||||
|
// Lazy proxy — defers the actual SecureStore call to runtime
|
||||||
|
getItem: async (key: string) => {
|
||||||
|
const { getItemAsync } = await import("expo-secure-store");
|
||||||
|
return getItemAsync(key);
|
||||||
|
},
|
||||||
|
setItem: async (key: string, value: string) => {
|
||||||
|
const { setItemAsync } = await import("expo-secure-store");
|
||||||
|
return setItemAsync(key, value);
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
const { deleteItemAsync } = await import("expo-secure-store");
|
||||||
|
return deleteItemAsync(key);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
20
apps/native/src/stores/household.store.ts
Normal file
20
apps/native/src/stores/household.store.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type Household = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HouseholdState = {
|
||||||
|
currentHousehold: Household | null;
|
||||||
|
households: Household[];
|
||||||
|
setCurrentHousehold: (household: Household | null) => void;
|
||||||
|
setHouseholds: (households: Household[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHouseholdStore = create<HouseholdState>((set) => ({
|
||||||
|
currentHousehold: null,
|
||||||
|
households: [],
|
||||||
|
setCurrentHousehold: (currentHousehold) => set({ currentHousehold }),
|
||||||
|
setHouseholds: (households) => set({ households }),
|
||||||
|
}));
|
||||||
43
apps/native/src/utils/date.ts
Normal file
43
apps/native/src/utils/date.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Shared date/month utilities used across the native app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Returns the current month as "YYYY-MM". */
|
||||||
|
export function currentMonthStr(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds `delta` months to a "YYYY-MM" string and returns the resulting "YYYY-MM". */
|
||||||
|
export function addMonths(monthStr: string, delta: number): string {
|
||||||
|
const [y, m] = monthStr.split("-").map(Number);
|
||||||
|
const d = new Date(y!, m! - 1 + delta);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a "YYYY-MM" string as a localized German month+year label (e.g. "März 2026"). */
|
||||||
|
export function monthLabel(month: string): string {
|
||||||
|
const [year, m] = month.split("-");
|
||||||
|
return new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(
|
||||||
|
new Date(Number(year), Number(m) - 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first-of-month and last-of-month ISO timestamps for a "YYYY-MM" string.
|
||||||
|
* Useful for building date-range query filters.
|
||||||
|
*/
|
||||||
|
export function monthDateRange(month: string): [from: string, to: string] {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
const lastDay = new Date(y!, m!, 0).getDate();
|
||||||
|
return [
|
||||||
|
`${month}-01T00:00:00.000Z`,
|
||||||
|
`${month}-${String(lastDay).padStart(2, "0")}T23:59:59.999Z`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns today's date as "YYYY-MM-DD". */
|
||||||
|
export function todayIso(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
31
apps/native/src/utils/format.ts
Normal file
31
apps/native/src/utils/format.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Shared formatting utilities used across the native app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** EUR formatter instance shared by both formatEur variants. */
|
||||||
|
const eurFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a number as EUR currency using German locale.
|
||||||
|
* Uses Math.abs by default — callers add +/- signs where needed.
|
||||||
|
* Pass `absolute: false` to preserve the original sign.
|
||||||
|
*/
|
||||||
|
export function formatEur(amount: number, absolute = true): string {
|
||||||
|
return eurFormatter.format(absolute ? Math.abs(amount) : amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an ISO date string for display.
|
||||||
|
* Returns "Heute" / "Today" for today, otherwise "D. MonthName".
|
||||||
|
*/
|
||||||
|
export function formatDateDisplay(isoDate: string, language: string, todayLabel: string): string {
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||||
|
if (isoDate.startsWith(todayStr)) return todayLabel;
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
const locale = language === "en" ? "en-US" : "de-DE";
|
||||||
|
const monthName = new Intl.DateTimeFormat(locale, { month: "long" }).format(
|
||||||
|
new Date(2024, d.getMonth()),
|
||||||
|
);
|
||||||
|
return `${d.getDate()}. ${monthName}`;
|
||||||
|
}
|
||||||
35
apps/native/src/utils/numpad.ts
Normal file
35
apps/native/src/utils/numpad.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Shared numpad input handler for EUR amount entry.
|
||||||
|
* Used by every modal with a custom numpad (transactions, debts, fixed costs, etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Processes a numpad key press and returns the updated amount string. */
|
||||||
|
export function handleNumpadKey(current: string, key: string): string {
|
||||||
|
if (key === "\u232B") {
|
||||||
|
return current.length > 1 ? current.slice(0, -1) : "0";
|
||||||
|
}
|
||||||
|
if (key === ",") {
|
||||||
|
return current.includes(",") ? current : current + ",";
|
||||||
|
}
|
||||||
|
if (current === "0") {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
const parts = current.split(",");
|
||||||
|
if (parts[1] !== undefined && parts[1].length >= 2) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return current + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a German-format amount string ("1.234,56") to a number. */
|
||||||
|
export function parseAmountStr(amountStr: string): number {
|
||||||
|
return parseFloat(amountStr.replace(",", ".")) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The numpad key layout used across all modals. */
|
||||||
|
export const NUMPAD_KEYS: string[][] = [
|
||||||
|
["1", "2", "3"],
|
||||||
|
["4", "5", "6"],
|
||||||
|
["7", "8", "9"],
|
||||||
|
[",", "0", "\u232B"],
|
||||||
|
];
|
||||||
134
apps/native/src/utils/receipt-parser.ts
Normal file
134
apps/native/src/utils/receipt-parser.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
export type ScanResult = {
|
||||||
|
amount: number | null;
|
||||||
|
label: string | null;
|
||||||
|
date: string | null;
|
||||||
|
confidence: number; // 0-100
|
||||||
|
rawText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KNOWN_MERCHANTS = [
|
||||||
|
"BILLA",
|
||||||
|
"SPAR",
|
||||||
|
"HOFER",
|
||||||
|
"LIDL",
|
||||||
|
"PENNY",
|
||||||
|
"MERKUR",
|
||||||
|
"REWE",
|
||||||
|
"EDEKA",
|
||||||
|
"ALDI",
|
||||||
|
"DM",
|
||||||
|
"MÜLLER",
|
||||||
|
"ROSSMANN",
|
||||||
|
"MCDONALD",
|
||||||
|
"MCDONALDS",
|
||||||
|
"BURGER KING",
|
||||||
|
"STARBUCKS",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Patterns tried in order to extract the total amount
|
||||||
|
const AMOUNT_PATTERNS: RegExp[] = [
|
||||||
|
/(?:summe|gesamt|total|zu zahlen|zu pay|betrag|sum)[\s:]*(\d+[,.]?\d*[,.]\d{2})/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
function normaliseAmount(raw: string): number {
|
||||||
|
// Replace comma decimal separator with dot, strip thousands separators
|
||||||
|
// e.g. "1.234,56" → 1234.56, "12,34" → 12.34, "12.34" → 12.34
|
||||||
|
const cleaned = raw.replace(/\./g, "").replace(",", ".");
|
||||||
|
return parseFloat(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAmount(text: string): number | null {
|
||||||
|
// Pattern 1: keyword-based
|
||||||
|
for (const pattern of AMOUNT_PATTERNS) {
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
const match = pattern.exec(text);
|
||||||
|
if (match) {
|
||||||
|
const val = normaliseAmount(match[1]);
|
||||||
|
if (!isNaN(val)) return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: highest "digits€" match
|
||||||
|
const withSuffix = /(\d+[,.]\d{2})\s*€/g;
|
||||||
|
let highest: number | null = null;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = withSuffix.exec(text)) !== null) {
|
||||||
|
const val = normaliseAmount(m[1]);
|
||||||
|
if (!isNaN(val) && (highest === null || val > highest)) {
|
||||||
|
highest = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (highest !== null) return highest;
|
||||||
|
|
||||||
|
// Pattern 3: "€ digits" match
|
||||||
|
const withPrefix = /€\s*(\d+[,.]\d{2})/g;
|
||||||
|
while ((m = withPrefix.exec(text)) !== null) {
|
||||||
|
const val = normaliseAmount(m[1]);
|
||||||
|
if (!isNaN(val) && (highest === null || val > highest)) {
|
||||||
|
highest = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLabel(text: string): { label: string | null; isKnown: boolean } {
|
||||||
|
const upper = text.toUpperCase();
|
||||||
|
|
||||||
|
for (const merchant of KNOWN_MERCHANTS) {
|
||||||
|
if (upper.includes(merchant)) {
|
||||||
|
return { label: merchant.charAt(0) + merchant.slice(1).toLowerCase(), isKnown: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take first non-empty line that is not digits-only and not a date/time/address line
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (/^\d+$/.test(line)) continue; // digits-only
|
||||||
|
if (/\d{2}[.:/]\d{2}/.test(line)) continue; // date/time pattern
|
||||||
|
return { label: line, isKnown: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: null, isKnown: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDate(text: string): string | null {
|
||||||
|
// DD.MM.YYYY
|
||||||
|
const dmyFull = /\b(\d{2})\.(\d{2})\.(\d{4})\b/;
|
||||||
|
let m = dmyFull.exec(text);
|
||||||
|
if (m) {
|
||||||
|
return `${m[3]}-${m[2]}-${m[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DD.MM.YY
|
||||||
|
const dmyShort = /\b(\d{2})\.(\d{2})\.(\d{2})\b/;
|
||||||
|
m = dmyShort.exec(text);
|
||||||
|
if (m) {
|
||||||
|
const year = parseInt(m[3], 10) >= 50 ? `19${m[3]}` : `20${m[3]}`;
|
||||||
|
return `${year}-${m[2]}-${m[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY-MM-DD
|
||||||
|
const iso = /\b(\d{4})-(\d{2})-(\d{2})\b/;
|
||||||
|
m = iso.exec(text);
|
||||||
|
if (m) {
|
||||||
|
return `${m[1]}-${m[2]}-${m[3]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseReceiptText(text: string): ScanResult {
|
||||||
|
const amount = extractAmount(text);
|
||||||
|
const { label, isKnown } = extractLabel(text);
|
||||||
|
const date = extractDate(text);
|
||||||
|
|
||||||
|
let confidence = 0;
|
||||||
|
if (amount !== null) confidence += 50;
|
||||||
|
if (label !== null) confidence += isKnown ? 30 : 10;
|
||||||
|
if (date !== null) confidence += 20;
|
||||||
|
confidence = Math.min(confidence, 100);
|
||||||
|
|
||||||
|
return { amount, label, date, confidence, rawText: text };
|
||||||
|
}
|
||||||
@@ -4,8 +4,15 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx"]
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/server/.env.example
Normal file
17
apps/server/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/haushaltsapp
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
CORS_ORIGIN=http://localhost:3001
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
NODE_ENV=development
|
||||||
|
APPLE_CLIENT_ID=
|
||||||
|
APPLE_TEAM_ID=
|
||||||
|
APPLE_KEY_ID=
|
||||||
|
APPLE_PRIVATE_KEY=
|
||||||
|
MOBILE_APP_SCHEME=haushaltsapp://
|
||||||
|
SMTP_HOST=localhost
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=noreply@haushaltsapp.local
|
||||||
2
apps/server/bunfig.toml
Normal file
2
apps/server/bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./src/__tests__/setup.ts"]
|
||||||
@@ -7,14 +7,17 @@
|
|||||||
"check-types": "tsc -b",
|
"check-types": "tsc -b",
|
||||||
"compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server",
|
"compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server",
|
||||||
"dev": "bun run --hot src/index.ts",
|
"dev": "bun run --hot src/index.ts",
|
||||||
"start": "bun run dist/index.mjs"
|
"start": "bun run dist/index.mjs",
|
||||||
|
"test": "bun test ./src/__tests__"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@haushaltsApp/auth": "workspace:*",
|
"@haushaltsApp/auth": "workspace:*",
|
||||||
"@haushaltsApp/db": "workspace:*",
|
"@haushaltsApp/db": "workspace:*",
|
||||||
"@haushaltsApp/env": "workspace:*",
|
"@haushaltsApp/env": "workspace:*",
|
||||||
|
"@haushaltsApp/shared": "workspace:*",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"@hono/zod-validator": "^0.4.3",
|
||||||
"hono": "^4.8.2",
|
"hono": "^4.8.2",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
65
apps/server/src/__tests__/helpers/test-context.ts
Normal file
65
apps/server/src/__tests__/helpers/test-context.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { auth } from "@haushaltsApp/auth";
|
||||||
|
import app from "../../index";
|
||||||
|
|
||||||
|
export interface TestContext {
|
||||||
|
token: string;
|
||||||
|
householdId: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestContext(suffix?: string): Promise<TestContext> {
|
||||||
|
const unique = suffix ?? String(Date.now());
|
||||||
|
const email = `test-${unique}@example.com`;
|
||||||
|
const password = "TestPassword123!";
|
||||||
|
const name = `Test User ${unique}`;
|
||||||
|
|
||||||
|
// 1. Register user
|
||||||
|
const signUpRes = await app.request("/api/auth/sign-up/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, name }),
|
||||||
|
});
|
||||||
|
if (!signUpRes.ok) throw new Error(`Sign-up failed: ${await signUpRes.text()}`);
|
||||||
|
|
||||||
|
// 2. Sign in to get token
|
||||||
|
const signInRes = await app.request("/api/auth/sign-in/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!signInRes.ok) throw new Error(`Sign-in failed: ${await signInRes.text()}`);
|
||||||
|
const signInBody = (await signInRes.json()) as { token: string; user: { id: string } };
|
||||||
|
const token = signInBody.token;
|
||||||
|
const userId = signInBody.user.id;
|
||||||
|
|
||||||
|
// 3. Create organization (household) via Better Auth API
|
||||||
|
const orgRes = await auth.api.createOrganization({
|
||||||
|
body: {
|
||||||
|
name: `Household ${unique}`,
|
||||||
|
slug: `household-${unique}`,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const householdId = orgRes.id;
|
||||||
|
|
||||||
|
// 4. Seed default categories
|
||||||
|
await app.request("/api/households/setup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"x-household-id": householdId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, householdId, userId, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeaders(token: string, householdId: string) {
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"x-household-id": householdId,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
apps/server/src/__tests__/routes/auth.test.ts
Normal file
54
apps/server/src/__tests__/routes/auth.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterAll, describe, expect, it } from "bun:test";
|
||||||
|
import { db, eq } from "@haushaltsApp/db";
|
||||||
|
import { account, session, user } from "@haushaltsApp/db/schema";
|
||||||
|
import app from "../../index";
|
||||||
|
|
||||||
|
const TEST_EMAIL = `test-${Date.now()}@example.com`;
|
||||||
|
const TEST_PASSWORD = "TestPassword123!";
|
||||||
|
const TEST_NAME = "Test User";
|
||||||
|
|
||||||
|
describe("Auth Routes", () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up test user and related records
|
||||||
|
const [testUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.email, TEST_EMAIL));
|
||||||
|
if (testUser) {
|
||||||
|
await db.delete(session).where(eq(session.userId, testUser.id));
|
||||||
|
await db.delete(account).where(eq(account.userId, testUser.id));
|
||||||
|
await db.delete(user).where(eq(user.id, testUser.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/auth/sign-up/email — creates user", async () => {
|
||||||
|
const res = await app.request("/api/auth/sign-up/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
name: TEST_NAME,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { user: { email: string } };
|
||||||
|
expect(body.user.email).toBe(TEST_EMAIL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/auth/sign-in/email — returns session", async () => {
|
||||||
|
const res = await app.request("/api/auth/sign-in/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { token?: string; user?: { email: string } };
|
||||||
|
// Better Auth sign-in returns { token, user } (session stored server-side via cookie)
|
||||||
|
expect(body.token).toBeDefined();
|
||||||
|
expect(body.user?.email).toBe(TEST_EMAIL);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/server/src/__tests__/routes/health.test.ts
Normal file
13
apps/server/src/__tests__/routes/health.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import app from "../../index";
|
||||||
|
|
||||||
|
describe("GET /health", () => {
|
||||||
|
it("returns 200 with status ok", async () => {
|
||||||
|
const res = await app.request("/health");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json() as { status: string; timestamp: string };
|
||||||
|
expect(body.status).toBe("ok");
|
||||||
|
expect(typeof body.timestamp).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user