feat: Implement mobile application and lead processing utilities.

This commit is contained in:
2026-02-19 14:21:51 +01:00
parent fca42db4d2
commit c53a71a5f9
120 changed files with 24080 additions and 851 deletions

116
innungsapp/CLAUDE.md Normal file
View File

@@ -0,0 +1,116 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**InnungsApp** is a multi-tenant SaaS platform for German trade guilds (Innungen). It consists of:
- **Admin Dashboard**: Next.js 15 web app for guild administrators
- **Mobile App**: Expo React Native app for guild members (iOS + Android)
- **Shared Package**: Prisma ORM schema, types, and utilities
## Commands
All commands run from `innungsapp/` root unless noted.
```bash
# Development
pnpm install # Install all workspace dependencies
pnpm dev # Start all apps in parallel (Turborepo)
# Per-app dev
pnpm --filter admin dev # Admin only (Next.js on :3000)
pnpm --filter mobile dev # Mobile only (Expo)
cd apps/mobile && npx expo run:android
cd apps/mobile && npx expo run:ios
# Type checking & linting
pnpm type-check # tsc --noEmit across all apps
pnpm lint # ESLint across all apps
# Database (Prisma via shared package)
pnpm db:generate # Regenerate Prisma client after schema changes
pnpm db:migrate # Run migrations (dev)
pnpm db:push # Push schema without migration (prototype)
pnpm db:studio # Open Prisma Studio
pnpm db:seed # Seed with test data
pnpm db:reset # Drop + re-migrate + re-seed
# Deployment
vercel --cwd apps/admin # Deploy admin to Vercel
cd apps/mobile && eas build --platform all --profile production
cd apps/mobile && eas submit --platform all
```
## Architecture
### Monorepo Structure
- **pnpm Workspaces + Turborepo** — `apps/admin`, `apps/mobile`, `packages/shared`
- `packages/shared` exports Prisma client, schema types, and shared utilities
- Both apps import from `@innungsapp/shared`
### Data Flow
```
Mobile App (Expo)
▼ HTTP (tRPC)
Admin App (Next.js API Routes)
▼ Prisma ORM
PostgreSQL Database
```
The mobile app calls the admin app's tRPC API (`/api/trpc`). There is no separate backend — the Next.js app serves both the admin UI and the API.
### tRPC Procedure Hierarchy
Three protection levels in `apps/admin/server/trpc.ts`:
- `publicProcedure` — No auth
- `protectedProcedure` — Session required
- `memberProcedure` — Session + valid org membership (injects `orgId` and `role`)
Routers are in `apps/admin/server/routers/`: `members`, `news`, `termine`, `stellen`, `organizations`.
### Multi-Tenancy
Every resource (member, news, event, job listing) is scoped to an `Organization`. The `memberProcedure` extracts `orgId` from the session and all queries filter by it. Org plan types: `pilot`, `standard`, `pro`, `verband`.
### Authentication
- **better-auth** with magic links (email-based, passwordless)
- Admin creates a member → email invitation sent via SMTP → member sets up account
- Session stored in DB; mobile app persists session token in AsyncStorage
- Auth handler: `apps/admin/app/api/auth/[...all]/route.ts`
### Mobile Routing (Expo Router)
File-based routing with two route groups:
- `(auth)/` — Login, check-email (unauthenticated)
- `(app)/` — Tab navigation: home, members, news, stellen, termine, profil (requires session)
Zustand (`store/auth.store.ts`) holds auth state; React Query handles server state via tRPC.
### Admin Routing (Next.js App Router)
- `/login` — Magic link login
- `/dashboard` — Protected layout with sidebar
- `/dashboard/mitglieder` — Member CRUD
- `/dashboard/news` — News management
- `/dashboard/termine` — Event management
- `/dashboard/stellen` — Job listings
- `/dashboard/einstellungen` — Org settings (AVV acceptance)
File uploads are stored locally in `apps/admin/uploads/` and served via `/api/uploads/[...path]`.
### Environment Variables
Required in `apps/admin/.env` (see `.env.example`):
- `DATABASE_URL` — PostgreSQL connection
- `BETTER_AUTH_SECRET` / `BETTER_AUTH_URL` — Auth config
- `SMTP_*` — Email for magic links
- `NEXT_PUBLIC_APP_URL` — Admin public URL
- `EXPO_PUBLIC_API_URL` — Mobile points to admin API
- `UPLOAD_DIR` / `UPLOAD_MAX_SIZE_MB` — File storage
## Key Conventions
- **Styling**: Tailwind CSS in admin; NativeWind v4 (Tailwind syntax) in mobile
- **Validation**: Zod schemas defined inline with tRPC procedures
- **Dates**: `date-fns` for formatting
- **Icons**: `lucide-react` (admin), `@expo/vector-icons` (mobile)
- **Schema changes**: Always run `pnpm db:generate` after editing `packages/shared/prisma/schema.prisma`
- **tRPC client (mobile)**: configured in `apps/mobile/lib/trpc.ts`, uses `superjson` transformer

137
innungsapp/README.md Normal file
View File

@@ -0,0 +1,137 @@
# InnungsApp
Die digitale Plattform für Innungen — News, Mitgliederverzeichnis, Termine und Lehrlingsbörse.
## Stack
| Schicht | Technologie |
|---|---|
| **Monorepo** | pnpm Workspaces + Turborepo |
| **Mobile App** | Expo (React Native) + Expo Router |
| **Admin Dashboard** | Next.js 15 (App Router) |
| **API** | tRPC v11 |
| **Auth** | better-auth (Magic Links) |
| **Datenbank** | PostgreSQL + Prisma ORM |
| **Styling Mobile** | NativeWind v4 (Tailwind CSS) |
| **Styling Admin** | Tailwind CSS |
| **State Management** | Zustand (Mobile) + React Query (beide Apps) |
## Projekt-Struktur
```
innungsapp/
├── apps/
│ ├── mobile/ # Expo React Native App (iOS + Android)
│ └── admin/ # Next.js Admin Dashboard
├── packages/
│ └── shared/ # TypeScript-Typen + Prisma Client
└── ...
```
## Setup
### Voraussetzungen
- Node.js >= 20
- pnpm >= 9
- PostgreSQL-Datenbank
- SMTP-Server (für Magic Links)
### 1. Abhängigkeiten installieren
```bash
pnpm install
```
### 2. Umgebungsvariablen
```bash
cp .env.example apps/admin/.env.local
# .env.local befüllen (DATABASE_URL, BETTER_AUTH_SECRET, SMTP_*)
```
### 3. Datenbank einrichten
```bash
# Prisma Client generieren
pnpm db:generate
# Migrationen anwenden
pnpm db:migrate
# Demo-Daten einspielen (optional)
pnpm db:seed
```
### 4. Entwicklung starten
```bash
# Admin Dashboard (http://localhost:3000)
pnpm --filter @innungsapp/admin dev
# Mobile App (Expo DevTools)
pnpm --filter @innungsapp/mobile dev
```
Oder alles parallel:
```bash
pnpm dev
```
## Datenbank-Schema
Das Schema befindet sich in `packages/shared/prisma/schema.prisma`.
Wichtige Tabellen:
- `organizations` — Innungen (Multi-Tenancy)
- `members` — Mitglieder (verknüpft mit Auth-User nach Einladung)
- `user_roles` — Berechtigungen (admin | member)
- `news`, `news_reads`, `news_attachments` — News-System
- `termine`, `termin_anmeldungen` — Terminverwaltung
- `stellen` — Lehrlingsbörse (öffentlich lesbar)
## Auth-Flow
1. **Admin einrichten:** Seed-Daten oder manuell in der DB
2. **Mitglied einladen:** Admin erstellt Mitglied → "Einladung senden" → Magic Link per E-Mail
3. **Mitglied loggt ein:** Magic Link → Session → App-Zugang
## API (tRPC)
Alle API-Endpunkte sind typsicher über tRPC definiert:
- `organizations.*` — Org-Einstellungen, Stats, AVV
- `members.*` — CRUD, Einladungen
- `news.*` — CRUD, Lesestatus, Push-Benachrichtigungen
- `termine.*` — CRUD, Anmeldungen
- `stellen.*` — Public + Auth-geschützte Endpunkte
## Deployment
### Admin (Vercel)
```bash
# Umgebungsvariablen in Vercel setzen:
# DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, SMTP_*
# Deploy
vercel --cwd apps/admin
```
### Mobile (EAS Build)
```bash
cd apps/mobile
eas build --platform all --profile production
eas submit --platform all
```
## DSGVO / AVV
- AVV-Akzeptanz in Admin → Einstellungen (Pflichtfeld vor Go-Live)
- Alle personenbezogenen Daten in EU-Region (Datenbankserver in Deutschland empfohlen)
- Keine Daten an Dritte außer Expo Push API (anonymisierte Token)
## Roadmap
Siehe `innung-app-mvp.md` für die vollständige Roadmap.

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { token } = await req.json()
if (!token || typeof token !== 'string') {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 })
}
// Store push token on the member record
await prisma.member.updateMany({
where: { userId: session.user.id },
data: { pushToken: token },
})
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,155 @@
'use client'
import { use } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared'
export default function MitgliedEditPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
const updateMutation = trpc.members.update.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const resendMutation = trpc.members.resendInvite.useMutation()
const [form, setForm] = useState({
name: '',
betrieb: '',
sparte: '',
ort: '',
telefon: '',
email: '',
status: 'aktiv' as 'aktiv' | 'ruhend' | 'ausgetreten',
istAusbildungsbetrieb: false,
seit: undefined as number | undefined,
})
useEffect(() => {
if (member) {
setForm({
name: member.name,
betrieb: member.betrieb,
sparte: member.sparte,
ort: member.ort,
telefon: member.telefon ?? '',
email: member.email,
status: member.status,
istAusbildungsbetrieb: member.istAusbildungsbetrieb,
seit: member.seit ?? undefined,
})
}
}, [member])
if (isLoading) return <div className="text-gray-500">Wird geladen...</div>
if (!member) return null
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
updateMutation.mutate({ id, data: form })
}
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500'
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-4">
<Link href="/dashboard/mitglieder" className="text-gray-400 hover:text-gray-600">
Zurück
</Link>
<h1 className="text-2xl font-bold text-gray-900">Mitglied bearbeiten</h1>
</div>
{/* Invite Status */}
<div className="bg-white rounded-xl border shadow-sm p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-700">App-Zugang</p>
<p className="text-xs text-gray-500 mt-0.5">
{member.userId
? '✓ Mitglied hat sich eingeloggt'
: 'Noch nicht eingeladen / eingeloggt'}
</p>
</div>
{!member.userId && (
<button
onClick={() => resendMutation.mutate({ memberId: id })}
disabled={resendMutation.isPending}
className="text-sm text-brand-600 hover:underline disabled:opacity-50"
>
{resendMutation.isPending ? 'Sende...' : resendMutation.isSuccess ? '✓ Gesendet' : 'Einladung senden'}
</button>
)}
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
<select value={form.sparte} onChange={(e) => setForm({ ...form, sparte: e.target.value })} className={inputClass}>
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
<input value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
<input type="tel" value={form.telefon} onChange={(e) => setForm({ ...form, telefon: e.target.value })} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })} className={inputClass}>
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
<input type="number" value={form.seit ?? ''} onChange={(e) => setForm({ ...form, seit: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
</div>
<div className="col-span-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.istAusbildungsbetrieb} onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })} className="rounded border-gray-300 text-brand-500 focus:ring-brand-500" />
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
</label>
</div>
</div>
{updateMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">{updateMutation.error.message}</p>
)}
<div className="flex gap-3 pt-2 border-t">
<button type="submit" disabled={updateMutation.isPending} className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors">
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</button>
<Link href="/dashboard/mitglieder" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
Abbrechen
</Link>
</div>
</form>
</div>
)
}

View File

@@ -2,6 +2,9 @@
"name": "@innungsapp/admin",
"version": "0.1.0",
"private": true,
"exports": {
".": "./server/routers/index.ts"
},
"scripts": {
"dev": "next dev",
"build": "next build",

6
innungsapp/apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

View File

@@ -26,7 +26,10 @@
"backgroundColor": "#E63946"
},
"package": "de.innungsapp.mobile",
"permissions": ["RECEIVE_BOOT_COMPLETED", "SCHEDULE_EXACT_ALARM"]
"permissions": [
"RECEIVE_BOOT_COMPLETED",
"SCHEDULE_EXACT_ALARM"
]
},
"web": {
"bundler": "metro",
@@ -50,10 +53,11 @@
{
"calendarPermission": "Die App benötigt Zugriff auf Ihren Kalender."
}
]
],
"expo-web-browser"
],
"experiments": {
"typedRoutes": true
}
}
}
}

View File

@@ -1,11 +1,7 @@
import { Tabs } from 'expo-router'
import { useAuthStore } from '@/store/auth.store'
import { Redirect } from 'expo-router'
import { Tabs, Redirect } from 'expo-router'
import { Platform } from 'react-native'
function TabIcon({ emoji }: { emoji: string }) {
return null // Replaced by tabBarIcon in options
}
import { Ionicons } from '@expo/vector-icons'
import { useAuthStore } from '@/store/auth.store'
export default function AppLayout() {
const session = useAuthStore((s) => s.session)
@@ -17,62 +13,75 @@ export default function AppLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#E63946',
tabBarInactiveTintColor: '#6b7280',
tabBarActiveTintColor: '#003B7E',
tabBarInactiveTintColor: '#64748B',
tabBarStyle: {
borderTopColor: '#e5e7eb',
backgroundColor: 'white',
paddingBottom: Platform.OS === 'ios' ? 8 : 4,
height: Platform.OS === 'ios' ? 82 : 60,
borderTopWidth: 1,
borderTopColor: '#E2E8F0',
backgroundColor: '#FFFFFF',
height: Platform.OS === 'ios' ? 88 : 64,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
paddingTop: 8,
},
headerStyle: { backgroundColor: 'white' },
headerTitleStyle: { fontWeight: '700', color: '#111827' },
headerShadowVisible: false,
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.1,
},
headerShown: false,
}}
>
<Tabs.Screen
name="news"
name="home/index"
options={{
title: 'News',
tabBarIcon: ({ color }) => (
/* Replace with actual icons after @expo/vector-icons setup */
<TabIcon emoji="📰" />
title: 'Start',
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'home' : 'home-outline'} size={23} color={color} />
),
headerShown: false,
}}
/>
<Tabs.Screen
name="members"
name="news/index"
options={{
title: 'Mitglieder',
tabBarIcon: () => <TabIcon emoji="👥" />,
headerShown: false,
title: 'Aktuelles',
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'newspaper' : 'newspaper-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen
name="termine"
name="termine/index"
options={{
title: 'Termine',
tabBarIcon: () => <TabIcon emoji="📅" />,
headerShown: false,
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'calendar' : 'calendar-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen
name="stellen"
name="stellen/index"
options={{
title: 'Stellen',
tabBarIcon: () => <TabIcon emoji="🎓" />,
headerShown: false,
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'briefcase' : 'briefcase-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen
name="profil"
name="profil/index"
options={{
title: 'Profil',
tabBarIcon: () => <TabIcon emoji="👤" />,
headerShown: false,
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'person' : 'person-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen name="members/index" options={{ href: null }} />
<Tabs.Screen name="news/[id]" options={{ href: null }} />
<Tabs.Screen name="members/[id]" options={{ href: null }} />
<Tabs.Screen name="termine/[id]" options={{ href: null }} />
<Tabs.Screen name="stellen/[id]" options={{ href: null }} />
</Tabs>
)
}

View File

@@ -0,0 +1,464 @@
import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Platform, Image } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { useRouter } from 'expo-router'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
import { useNewsList } from '@/hooks/useNews'
import { useTermineListe } from '@/hooks/useTermine'
import { useNewsReadStore } from '@/store/news.store'
// Helper to truncate text
function getNewsExcerpt(value: string) {
const normalized = value
.replace(/[#*_`>-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return normalized.length > 85 ? `${normalized.slice(0, 85)}...` : normalized
}
export default function HomeScreen() {
const router = useRouter()
const { data: newsItems = [] } = useNewsList()
const { data: termine = [] } = useTermineListe(true)
const readIds = useNewsReadStore((s) => s.readIds)
const latestNews = newsItems.slice(0, 2)
const upcomingEvents = termine.slice(0, 3)
const unreadCount = newsItems.filter((item) => !(item.isRead || readIds.has(item.id))).length
const QUICK_ACTIONS = [
{ label: 'Mitglieder', icon: 'people', color: '#003B7E', bg: '#E0F2FE', route: '/(app)/members' },
{ label: 'Termine', icon: 'calendar', color: '#B45309', bg: '#FEF3C7', route: '/(app)/termine' },
{ label: 'Stellen', icon: 'briefcase', color: '#059669', bg: '#D1FAE5', route: '/(app)/stellen' },
{ label: 'Profil', icon: 'person', color: '#4F46E5', bg: '#E0E7FF', route: '/(app)/profil' },
]
return (
<View style={styles.container}>
{/* Decorative Background Element */}
<View style={styles.bgDecoration} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Header Section */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>I</Text>
</View>
<View>
<Text style={styles.greeting}>Willkommen zurück,</Text>
<Text style={styles.username}>Demo Admin</Text>
</View>
</View>
<TouchableOpacity
style={styles.notificationBtn}
activeOpacity={0.8}
>
<Ionicons name="notifications-outline" size={22} color="#1E293B" />
{unreadCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unreadCount}</Text>
</View>
)}
</TouchableOpacity>
</View>
{/* Search Bar */}
<View style={styles.searchContainer}>
<Ionicons name="search-outline" size={20} color="#94A3B8" />
<TextInput
editable={false}
placeholder="Suchen..."
placeholderTextColor="#94A3B8"
style={styles.searchInput}
/>
</View>
{/* Quick Actions Grid */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Schnellzugriff</Text>
<View style={styles.grid}>
{QUICK_ACTIONS.map((action, i) => (
<TouchableOpacity
key={i}
style={styles.gridItem}
activeOpacity={0.7}
onPress={() => router.push(action.route as never)}
>
<View style={[styles.gridIcon, { backgroundColor: action.bg }]}>
<Ionicons name={action.icon as any} size={24} color={action.color} />
</View>
<Text style={styles.gridLabel}>{action.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* News Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Aktuelles</Text>
<TouchableOpacity onPress={() => router.push('/(app)/news' as never)}>
<Text style={styles.linkText}>Alle anzeigen</Text>
</TouchableOpacity>
</View>
<View style={styles.cardsColumn}>
{latestNews.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.newsCard}
activeOpacity={0.9}
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
>
<View style={styles.newsHeader}>
<View style={styles.categoryBadge}>
<Text style={styles.categoryText}>
{NEWS_KATEGORIE_LABELS[item.kategorie]}
</Text>
</View>
<Text style={styles.dateText}>
{item.publishedAt ? format(item.publishedAt, 'dd. MMM', { locale: de }) : 'Entwurf'}
</Text>
</View>
<Text style={styles.newsTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.newsBody} numberOfLines={2}>{getNewsExcerpt(item.body)}</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Upcoming Events */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Anstehende Termine</Text>
<TouchableOpacity onPress={() => router.push('/(app)/termine' as never)}>
<Text style={styles.linkText}>Kalender</Text>
</TouchableOpacity>
</View>
<View style={styles.eventsList}>
{upcomingEvents.map((event, index) => (
<TouchableOpacity
key={event.id}
style={[styles.eventRow, index !== upcomingEvents.length - 1 && styles.eventBorder]}
onPress={() => router.push(`/(app)/termine/${event.id}` as never)}
activeOpacity={0.7}
>
<View style={styles.dateBox}>
<Text style={styles.dateMonth}>{format(event.datum, 'MMM', { locale: de })}</Text>
<Text style={styles.dateDay}>{format(event.datum, 'dd')}</Text>
</View>
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>{event.titel}</Text>
<Text style={styles.eventMeta} numberOfLines={1}>
{event.uhrzeit} {event.ort}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#CBD5E1" />
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
</SafeAreaView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8FAFC', // Slate-50
},
bgDecoration: {
position: 'absolute',
top: -100,
left: 0,
right: 0,
height: 400,
backgroundColor: '#003B7E', // Primary brand color
opacity: 0.05,
transform: [{ scaleX: 1.5 }, { scaleY: 1 }],
borderBottomLeftRadius: 200,
borderBottomRightRadius: 200,
},
safeArea: {
flex: 1,
},
scrollContent: {
padding: 20,
paddingBottom: 40,
gap: 24,
},
// Header
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 4,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: '#003B7E',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#003B7E',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
},
avatarText: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
},
greeting: {
fontSize: 13,
color: '#64748B',
fontWeight: '500',
},
username: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
},
notificationBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#E2E8F0',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 1,
},
badge: {
position: 'absolute',
top: -2,
right: -2,
backgroundColor: '#EF4444',
width: 16,
height: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: '#FFFFFF',
},
badgeText: {
color: '#FFF',
fontSize: 9,
fontWeight: 'bold',
},
// Search
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: '#E2E8F0',
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 12,
gap: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 4,
elevation: 1,
},
searchInput: {
flex: 1,
fontSize: 15,
color: '#0F172A',
},
// Sections
section: {
gap: 12,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
},
linkText: {
fontSize: 14,
fontWeight: '600',
color: '#003B7E',
},
// Grid
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
gridItem: {
width: '48%', // Approx half with gap
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
gap: 10,
borderWidth: 1,
borderColor: '#E2E8F0',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.02,
shadowRadius: 8,
elevation: 2,
},
gridIcon: {
width: 48,
height: 48,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
gridLabel: {
fontSize: 14,
fontWeight: '600',
color: '#334155',
},
// News Cards
cardsColumn: {
gap: 12,
},
newsCard: {
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 18,
borderWidth: 1,
borderColor: '#E2E8F0',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 6,
elevation: 2,
},
newsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
categoryBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
backgroundColor: '#F1F5F9',
borderRadius: 8,
},
categoryText: {
fontSize: 10,
fontWeight: '700',
color: '#475569',
textTransform: 'uppercase',
},
dateText: {
fontSize: 12,
color: '#94A3B8',
},
newsTitle: {
fontSize: 16,
fontWeight: '700',
color: '#0F172A',
marginBottom: 6,
lineHeight: 22,
},
newsBody: {
fontSize: 14,
color: '#64748B',
lineHeight: 20,
},
// Events List
eventsList: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
borderWidth: 1,
borderColor: '#E2E8F0',
paddingVertical: 4,
},
eventRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
gap: 14,
},
eventBorder: {
borderBottomWidth: 1,
borderBottomColor: '#F1F5F9',
},
dateBox: {
width: 50,
height: 50,
borderRadius: 14,
backgroundColor: '#F8FAFC',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#E2E8F0',
},
dateMonth: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
color: '#64748B',
marginBottom: -2,
},
dateDay: {
fontSize: 18,
fontWeight: '800',
color: '#0F172A',
},
eventInfo: {
flex: 1,
gap: 2,
},
eventTitle: {
fontSize: 15,
fontWeight: '700',
color: '#0F172A',
},
eventMeta: {
fontSize: 12,
color: '#64748B',
fontWeight: '500',
},
})

View File

@@ -1,107 +1,238 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, StyleSheet,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { Ionicons } from '@expo/vector-icons'
import { useMemberDetail } from '@/hooks/useMembers'
import { Avatar } from '@/components/ui/Avatar'
export default function MemberDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
const { data: member, isLoading } = useMemberDetail(id)
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
<SafeAreaView style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#003B7E" />
</SafeAreaView>
)
}
if (!member) return null
const fields = [
member.sparte ? ['SPARTE', member.sparte] : null,
member.ort ? ['ORT', member.ort] : null,
member.seit ? ['MITGLIED SEIT', String(member.seit)] : null,
].filter(Boolean) as [string, string][]
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* Nav */}
<View style={styles.navBar}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn} activeOpacity={0.7}>
<Ionicons name="chevron-back" size={20} color="#003B7E" />
<Text style={styles.backText}>Zurück</Text>
</TouchableOpacity>
</View>
<View style={styles.divider} />
<ScrollView>
{/* Profile Header */}
<View className="bg-white px-6 py-8 items-center border-b border-gray-100">
<Avatar
name={member.name}
imageUrl={member.avatarUrl ?? undefined}
size={80}
/>
<Text className="text-2xl font-bold text-gray-900 mt-4">{member.name}</Text>
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Hero */}
<View style={styles.hero}>
<Avatar name={member.name} imageUrl={member.avatarUrl ?? undefined} size={80} shadow />
<Text style={styles.heroName}>{member.name}</Text>
<Text style={styles.heroCompany}>{member.betrieb}</Text>
{member.istAusbildungsbetrieb && (
<View className="mt-2 bg-green-100 px-3 py-1 rounded-full">
<Text className="text-green-700 text-xs font-medium">
🎓 Ausbildungsbetrieb
</Text>
<View style={styles.ausbildungPill}>
<Ionicons name="school" size={12} color="#15803D" />
<Text style={styles.ausbildungText}>Ausbildungsbetrieb</Text>
</View>
)}
</View>
<View style={styles.divider} />
{/* Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<InfoRow label="Sparte" value={member.sparte} />
<InfoRow label="Ort" value={member.ort} />
{member.seit && (
<InfoRow label="Mitglied seit" value={String(member.seit)} />
)}
<View style={styles.card}>
{fields.map(([label, value], idx) => (
<View
key={label}
style={[styles.fieldRow, idx < fields.length - 1 && styles.fieldRowBorder]}
>
<Text style={styles.fieldLabel}>{label}</Text>
<Text style={styles.fieldValue}>{value}</Text>
</View>
))}
</View>
{/* Contact Buttons */}
<View className="mx-4 mt-4 gap-3">
{/* Actions */}
<View style={styles.actions}>
{member.telefon && (
<TouchableOpacity
onPress={() => Linking.openURL(`tel:${member.telefon}`)}
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
style={styles.btnPrimary}
activeOpacity={0.82}
>
<Text className="text-white text-xl">📞</Text>
<Text className="text-white font-semibold text-base">
Anrufen
</Text>
<Ionicons name="call" size={18} color="#FFFFFF" />
<Text style={styles.btnPrimaryText}>Anrufen</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() =>
Linking.openURL(
`mailto:${member.email}?subject=InnungsApp%20Anfrage`
)
}
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
onPress={() => Linking.openURL(`mailto:${member.email}`)}
style={styles.btnSecondary}
activeOpacity={0.8}
>
<Text className="text-gray-900 text-xl"></Text>
<Text className="text-gray-900 font-semibold text-base">
E-Mail senden
</Text>
<Ionicons name="mail-outline" size={18} color="#0F172A" />
<Text style={styles.btnSecondaryText}>E-Mail senden</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-32">{label}</Text>
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
</View>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
loadingContainer: {
flex: 1,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
},
navBar: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 16,
paddingVertical: 12,
},
backBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
backText: {
fontSize: 14,
fontWeight: '600',
color: '#003B7E',
},
divider: {
height: 1,
backgroundColor: '#E2E8F0',
},
hero: {
backgroundColor: '#FFFFFF',
alignItems: 'center',
paddingVertical: 32,
paddingHorizontal: 24,
},
heroName: {
fontSize: 21,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.4,
marginTop: 14,
textAlign: 'center',
},
heroCompany: {
fontSize: 14,
color: '#475569',
marginTop: 3,
textAlign: 'center',
},
ausbildungPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
backgroundColor: '#F0FDF4',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 99,
marginTop: 10,
},
ausbildungText: {
fontSize: 12,
color: '#15803D',
fontWeight: '600',
},
card: {
backgroundColor: '#FFFFFF',
marginHorizontal: 16,
marginTop: 16,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#1C1917',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 1,
},
fieldRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 13,
},
fieldRowBorder: {
borderBottomWidth: 1,
borderBottomColor: '#F9F9F9',
},
fieldLabel: {
fontSize: 10,
fontWeight: '700',
color: '#64748B',
letterSpacing: 0.8,
width: 110,
},
fieldValue: {
flex: 1,
fontSize: 14,
fontWeight: '500',
color: '#0F172A',
},
actions: {
marginHorizontal: 16,
marginTop: 16,
marginBottom: 32,
gap: 10,
},
btnPrimary: {
backgroundColor: '#003B7E',
borderRadius: 14,
paddingVertical: 15,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
shadowColor: '#003B7E',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.24,
shadowRadius: 10,
elevation: 5,
},
btnPrimaryText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '600',
letterSpacing: 0.2,
},
btnSecondary: {
backgroundColor: '#FFFFFF',
borderRadius: 14,
paddingVertical: 15,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
borderWidth: 1,
borderColor: '#E2E8F0',
},
btnSecondaryText: {
color: '#0F172A',
fontSize: 15,
fontWeight: '600',
},
})

View File

@@ -1,14 +1,10 @@
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
RefreshControl,
View, Text, FlatList, TextInput, TouchableOpacity, RefreshControl, StyleSheet,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { Ionicons } from '@expo/vector-icons'
import { useMembersList } from '@/hooks/useMembers'
import { useMembersFilterStore } from '@/store/members.store'
import { MemberCard } from '@/components/members/MemberCard'
import { EmptyState } from '@/components/ui/EmptyState'
@@ -20,78 +16,70 @@ export default function MembersScreen() {
const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe)
const setSearch = useMembersFilterStore((s) => s.setSearch)
const setNurAusbildungsbetriebe = useMembersFilterStore((s) => s.setNurAusbildungsbetriebe)
const { data, isLoading, refetch, isRefetching } = trpc.members.list.useQuery({
search: search || undefined,
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
status: 'aktiv',
})
const { data, isLoading, refetch, isRefetching } = useMembersList()
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* Header */}
<View className="bg-white px-4 pt-3 pb-2 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900 mb-3">Mitglieder</Text>
<View style={styles.header}>
<View style={styles.titleRow}>
<Text style={styles.screenTitle}>Mitglieder</Text>
{data && (
<Text style={styles.countText}>{data.length} gesamt</Text>
)}
</View>
{/* Search */}
<View className="flex-row items-center bg-gray-100 rounded-xl px-3 py-2 mb-2">
<Text className="text-gray-400 mr-2">🔍</Text>
{/* Search bar */}
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={16} color="#64748B" />
<TextInput
className="flex-1 text-sm text-gray-900"
placeholder="Name, Betrieb, Ort, Sparte..."
placeholderTextColor="#9ca3af"
style={styles.searchInput}
placeholder="Name, Betrieb, Ort, Sparte ..."
placeholderTextColor="#64748B"
value={search}
onChangeText={setSearch}
clearButtonMode="while-editing"
/>
</View>
{/* Filter: Ausbildungsbetriebe */}
{/* Training filter */}
<TouchableOpacity
onPress={() => setNurAusbildungsbetriebe(!nurAusbildungsbetriebe)}
className="flex-row items-center gap-2 py-1"
style={styles.toggleRow}
activeOpacity={0.7}
>
<View
className={`w-5 h-5 rounded border-2 items-center justify-center ${
nurAusbildungsbetriebe
? 'bg-brand-500 border-brand-500'
: 'border-gray-300 bg-white'
}`}
>
<View style={[styles.checkbox, nurAusbildungsbetriebe && styles.checkboxActive]}>
{nurAusbildungsbetriebe && (
<Text className="text-white text-xs"></Text>
<Ionicons name="checkmark" size={11} color="#FFFFFF" />
)}
</View>
<Text className="text-sm text-gray-600">Nur Ausbildungsbetriebe</Text>
<Text style={styles.toggleLabel}>Nur Ausbildungsbetriebe</Text>
</TouchableOpacity>
</View>
{/* List */}
<View style={styles.divider} />
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
}
renderItem={({ item }) => (
<MemberCard
member={item}
onPress={() => router.push(`/(app)/members/${item.id}`)}
onPress={() => router.push(`/(app)/members/${item.id}` as never)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="👥"
icon="people-outline"
title="Keine Mitglieder"
subtitle={search ? 'Keine Treffer für Ihre Suche' : 'Noch keine Mitglieder'}
subtitle={search ? 'Keine Treffer für deine Suche' : 'Noch keine Mitglieder eingetragen'}
/>
}
/>
@@ -99,3 +87,81 @@ export default function MembersScreen() {
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
header: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingTop: 18,
paddingBottom: 14,
},
titleRow: {
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'space-between',
marginBottom: 14,
},
screenTitle: {
fontSize: 28,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.5,
},
countText: {
fontSize: 13,
color: '#64748B',
fontWeight: '500',
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F4F4F5',
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 11,
gap: 8,
marginBottom: 10,
},
searchInput: {
flex: 1,
fontSize: 14,
color: '#0F172A',
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingTop: 2,
},
checkbox: {
width: 18,
height: 18,
borderRadius: 5,
borderWidth: 1.5,
borderColor: '#D4D4D8',
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
},
checkboxActive: {
backgroundColor: '#003B7E',
borderColor: '#003B7E',
},
toggleLabel: {
fontSize: 13,
color: '#52525B',
fontWeight: '500',
},
divider: {
height: 1,
backgroundColor: '#E2E8F0',
},
list: {
padding: 16,
gap: 10,
},
})

View File

@@ -1,87 +1,383 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
View, Text, ScrollView, TouchableOpacity, ActivityIndicator,
StyleSheet, Platform,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useEffect } from 'react'
import { trpc } from '@/lib/trpc'
import { useNewsReadStore } from '@/store/news.store'
import { Ionicons } from '@expo/vector-icons'
import { useNewsDetail } from '@/hooks/useNews'
import { AttachmentRow } from '@/components/news/AttachmentRow'
import { Badge } from '@/components/ui/Badge'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
// ---------------------------------------------------------------------------
// Lightweight markdown renderer (headings, bold, bullets, paragraphs)
// ---------------------------------------------------------------------------
function MarkdownBody({ source }: { source: string }) {
const blocks = source.split(/\n\n+/)
return (
<View style={md.container}>
{blocks.map((block, i) => {
const trimmed = block.trim()
if (!trimmed) return null
// H2 ##
if (trimmed.startsWith('## ')) {
return (
<Text key={i} style={md.h2}>
{trimmed.slice(3)}
</Text>
)
}
// H3 ###
if (trimmed.startsWith('### ')) {
return (
<Text key={i} style={md.h3}>
{trimmed.slice(4)}
</Text>
)
}
// H1 #
if (trimmed.startsWith('# ')) {
return (
<Text key={i} style={md.h1}>
{trimmed.slice(2)}
</Text>
)
}
// Bullet list
if (trimmed.startsWith('- ')) {
const items = trimmed.split('\n').filter(Boolean)
return (
<View key={i} style={md.list}>
{items.map((line, j) => {
const text = line.replace(/^-\s+/, '')
return (
<View key={j} style={md.listItem}>
<View style={md.bullet} />
<Text style={md.listText}>{renderInline(text)}</Text>
</View>
)
})}
</View>
)
}
// Paragraph (with inline bold)
return (
<Text key={i} style={md.paragraph}>
{renderInline(trimmed)}
</Text>
)
})}
</View>
)
}
/** Render **bold** inline within a Text node */
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*.*?\*\*)/)
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return (
<Text key={i} style={{ fontWeight: '700', color: '#0F172A' }}>
{part.slice(2, -2)}
</Text>
)
}
return <Text key={i}>{part}</Text>
})
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
export default function NewsDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const markRead = useNewsReadStore((s) => s.markRead)
const markReadMutation = trpc.news.markRead.useMutation()
const { data: news, isLoading, onOpen } = useNewsDetail(id)
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
useEffect(() => {
if (news) {
markRead(id)
markReadMutation.mutate({ newsId: id })
}
}, [news?.id])
useEffect(() => { if (news) onOpen() }, [news?.id])
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
<SafeAreaView style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#003B7E" />
</SafeAreaView>
)
}
if (!news) return null
const initials = (news.author?.name ?? 'I')
.split(' ')
.map((n) => n.charAt(0))
.slice(0, 2)
.join('')
return (
<SafeAreaView className="flex-1 bg-white" edges={['top']}>
{/* Header */}
<View className="flex-row items-center px-4 py-3 border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* Nav bar */}
<View style={styles.navBar}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backBtn}
activeOpacity={0.7}
>
<Ionicons name="chevron-back" size={22} color="#003B7E" />
<Text style={styles.backText}>Neuigkeiten</Text>
</TouchableOpacity>
<Text className="font-semibold text-gray-900 flex-1" numberOfLines={1}>
{news.title}
</Text>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* ── Hero header ────────────────────────────────────────── */}
<View style={styles.hero}>
<Badge
label={NEWS_KATEGORIE_LABELS[news.kategorie]}
kategorie={news.kategorie}
/>
<Text className="text-2xl font-bold text-gray-900 mt-3 mb-2">
{news.title}
</Text>
<Text style={styles.heroTitle}>{news.title}</Text>
<Text className="text-sm text-gray-500 mb-6">
{news.author?.name ?? 'InnungsApp'} ·{' '}
{news.publishedAt
? format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })
: ''}
</Text>
{/* Author + date row */}
<View style={styles.metaRow}>
<View style={styles.avatarCircle}>
<Text style={styles.avatarText}>{initials}</Text>
</View>
<View>
<Text style={styles.authorName}>{news.author?.name ?? 'Innung'}</Text>
{news.publishedAt && (
<Text style={styles.dateText}>
{format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })}
</Text>
)}
</View>
</View>
</View>
{/* Simple Markdown renderer — plain text for MVP */}
<Text className="text-base text-gray-700 leading-7">
{news.body.replace(/^#+\s/gm, '').replace(/\*\*(.*?)\*\*/g, '$1')}
</Text>
{/* ── Separator ──────────────────────────────────────────── */}
<View style={styles.heroSeparator} />
{/* Attachments */}
{/* ── Article body ───────────────────────────────────────── */}
<MarkdownBody source={news.body} />
{/* ── Attachments ────────────────────────────────────────── */}
{news.attachments.length > 0 && (
<View className="mt-8 border-t border-gray-100 pt-4">
<Text className="font-semibold text-gray-900 mb-3">Anhänge</Text>
{news.attachments.map((a) => (
<AttachmentRow key={a.id} attachment={a} />
))}
<View style={styles.attachmentsSection}>
<View style={styles.attachmentsHeader}>
<Ionicons name="attach" size={14} color="#64748B" />
<Text style={styles.attachmentsLabel}>
ANHÄNGE ({news.attachments.length})
</Text>
</View>
<View style={styles.attachmentsCard}>
{news.attachments.map((a, idx) => (
<View key={a.id}>
{idx > 0 && <View style={styles.attachmentsDivider} />}
<AttachmentRow attachment={a} />
</View>
))}
</View>
</View>
)}
{/* Bottom spacer */}
<View style={{ height: 48 }} />
</ScrollView>
</SafeAreaView>
)
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#FAFAFA',
},
loadingContainer: {
flex: 1,
backgroundColor: '#FAFAFA',
alignItems: 'center',
justifyContent: 'center',
},
// Nav
navBar: {
backgroundColor: '#FAFAFA',
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#E2E8F0',
},
backBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
alignSelf: 'flex-start',
},
backText: {
fontSize: 15,
fontWeight: '600',
color: '#003B7E',
},
// Hero
hero: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingTop: 22,
paddingBottom: 20,
gap: 12,
},
heroTitle: {
fontSize: 24,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.5,
lineHeight: 32,
marginTop: 4,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginTop: 4,
},
avatarCircle: {
width: 38,
height: 38,
borderRadius: 19,
backgroundColor: '#003B7E',
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
color: '#FFFFFF',
fontSize: 13,
fontWeight: '700',
letterSpacing: 0.5,
},
authorName: {
fontSize: 14,
fontWeight: '600',
color: '#0F172A',
lineHeight: 18,
},
dateText: {
fontSize: 12,
color: '#94A3B8',
marginTop: 1,
},
heroSeparator: {
height: 4,
backgroundColor: '#F1F5F9',
},
// Scroll
scrollContent: {
flexGrow: 1,
},
// Attachments
attachmentsSection: {
marginHorizontal: 20,
marginTop: 28,
},
attachmentsHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 10,
},
attachmentsLabel: {
fontSize: 11,
fontWeight: '700',
color: '#64748B',
letterSpacing: 0.8,
},
attachmentsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
paddingHorizontal: 16,
overflow: 'hidden',
...Platform.select({
ios: {
shadowColor: '#1C1917',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 10,
},
android: { elevation: 2 },
}),
},
attachmentsDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: '#F1F5F9',
},
})
// Markdown styles
const md = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingTop: 22,
paddingBottom: 8,
gap: 14,
},
h1: {
fontSize: 22,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.3,
lineHeight: 30,
},
h2: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
letterSpacing: -0.2,
lineHeight: 26,
marginTop: 8,
paddingBottom: 6,
borderBottomWidth: 2,
borderBottomColor: '#EFF6FF',
},
h3: {
fontSize: 16,
fontWeight: '700',
color: '#1E293B',
lineHeight: 24,
marginTop: 4,
},
paragraph: {
fontSize: 16,
color: '#334155',
lineHeight: 28,
letterSpacing: 0.1,
},
list: {
gap: 8,
},
listItem: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
},
bullet: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#003B7E',
marginTop: 10,
flexShrink: 0,
},
listText: {
flex: 1,
fontSize: 16,
color: '#334155',
lineHeight: 28,
},
})

View File

@@ -1,100 +1,157 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
ScrollView,
View, Text, FlatList, TouchableOpacity, RefreshControl, ScrollView, StyleSheet,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { trpc } from '@/lib/trpc'
import { useRouter } from 'expo-router'
import { useNewsList } from '@/hooks/useNews'
import { NewsCard } from '@/components/news/NewsCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
const FILTER_OPTIONS = [
const FILTERS = [
{ value: undefined, label: 'Alle' },
{ value: 'Wichtig', label: 'Wichtig' },
{ value: 'Pruefung', label: 'Prüfung' },
{ value: 'Foerderung', label: 'Förderung' },
{ value: 'Pruefung', label: 'Pruefung' },
{ value: 'Foerderung', label: 'Foerderung' },
{ value: 'Veranstaltung', label: 'Veranstaltung' },
]
export default function NewsScreen() {
const router = useRouter()
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
const { data, isLoading, refetch, isRefetching } = trpc.news.list.useQuery({
kategorie: kategorie as never,
})
const { data, isLoading, refetch, isRefetching } = useNewsList(kategorie)
const unreadCount = data?.filter((n) => !n.isRead).length ?? 0
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900">News</Text>
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}>
<View style={styles.titleRow}>
<Text style={styles.screenTitle}>Aktuelles</Text>
{unreadCount > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{unreadCount} neu</Text>
</View>
)}
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterScroll}
>
{FILTERS.map((opt) => {
const active = kategorie === opt.value
return (
<TouchableOpacity
key={String(opt.value)}
onPress={() => setKategorie(opt.value)}
style={[styles.chip, active && styles.chipActive]}
activeOpacity={0.85}
>
<Text style={[styles.chipLabel, active && styles.chipLabelActive]}>
{opt.label}
</Text>
</TouchableOpacity>
)
})}
</ScrollView>
</View>
{/* Kategorie Filter */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="bg-white border-b border-gray-100"
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 10, gap: 8 }}
>
{FILTER_OPTIONS.map((opt) => (
<TouchableOpacity
key={String(opt.value)}
onPress={() => setKategorie(opt.value)}
className={`px-4 py-1.5 rounded-full border ${
kategorie === opt.value
? 'bg-brand-500 border-brand-500'
: 'bg-white border-gray-200'
}`}
>
<Text
className={`text-sm font-medium ${
kategorie === opt.value ? 'text-white' : 'text-gray-600'
}`}
>
{opt.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
<View style={styles.divider} />
{/* List */}
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
}
renderItem={({ item }) => (
<NewsCard
news={item}
onPress={() => router.push(`/(app)/news/${item.id}`)}
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="📰"
title="Keine News"
subtitle="Noch keine Beiträge für diese Kategorie"
/>
<EmptyState icon="N" title="Keine News" subtitle="Noch keine Beitraege veroeffentlicht." />
}
/>
)}
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
header: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingTop: 14,
paddingBottom: 0,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14,
},
screenTitle: {
fontSize: 28,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.5,
},
unreadBadge: {
backgroundColor: '#EFF6FF',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 99,
},
unreadBadgeText: {
color: '#003B7E',
fontSize: 12,
fontWeight: '700',
},
filterScroll: {
paddingBottom: 14,
gap: 8,
paddingRight: 20,
},
chip: {
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 99,
borderWidth: 1,
borderColor: '#E2E8F0',
backgroundColor: '#FFFFFF',
},
chipActive: {
backgroundColor: '#003B7E',
borderColor: '#003B7E',
},
chipLabel: {
fontSize: 13,
fontWeight: '600',
color: '#64748B',
},
chipLabelActive: {
color: '#FFFFFF',
},
divider: {
height: 1,
backgroundColor: '#E2E8F0',
},
list: {
padding: 16,
gap: 10,
paddingBottom: 30,
},
})

View File

@@ -1,97 +1,330 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { View, Text, ScrollView, TouchableOpacity, Alert, StyleSheet } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { trpc } from '@/lib/trpc'
import { Ionicons } from '@expo/vector-icons'
import { useAuth } from '@/hooks/useAuth'
import { Avatar } from '@/components/ui/Avatar'
import { MOCK_MEMBER_ME } from '@/lib/mock-data'
type Item = {
label: string
icon: React.ComponentProps<typeof Ionicons>['name']
badge?: string
}
const MENU_ITEMS: Item[] = [
{ label: 'Persoenliche Daten', icon: 'person-outline' },
{ label: 'Betriebsdaten', icon: 'business-outline', badge: 'Aktiv' },
{ label: 'Mitteilungen', icon: 'notifications-outline', badge: '2' },
{ label: 'Sicherheit & Login', icon: 'shield-checkmark-outline' },
{ label: 'Hilfe & Support', icon: 'help-circle-outline' },
]
export default function ProfilScreen() {
const { signOut } = useAuth()
const { data: member, isLoading } = trpc.members.me.useQuery()
const member = MOCK_MEMBER_ME
const initials = member.name
.split(' ')
.slice(0, 2)
.map((chunk) => chunk[0]?.toUpperCase() ?? '')
.join('')
const openPlaceholder = () => {
Alert.alert('Hinweis', 'Dieser Bereich folgt in einer naechsten Version.')
}
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900">Mein Profil</Text>
</View>
<ScrollView>
{isLoading ? (
<View className="py-16 items-center">
<ActivityIndicator color="#E63946" />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.hero}>
<View style={styles.avatarWrap}>
<Text style={styles.avatarText}>{initials}</Text>
<TouchableOpacity style={styles.settingsBtn} activeOpacity={0.8} onPress={openPlaceholder}>
<Ionicons name="settings-outline" size={15} color="#64748B" />
</TouchableOpacity>
</View>
) : member ? (
<>
{/* Profile */}
<View className="bg-white px-4 py-8 items-center border-b border-gray-100">
<Avatar name={member.name} size={72} />
<Text className="text-xl font-bold text-gray-900 mt-4">{member.name}</Text>
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
<Text className="text-gray-400 text-sm mt-0.5">{member.org.name}</Text>
<Text style={styles.name}>{member.name}</Text>
<Text style={styles.role}>Innungsgeschaeftsfuehrer</Text>
<View style={styles.badgesRow}>
<View style={styles.statusBadge}>
<Text style={styles.statusBadgeText}>Admin-Status</Text>
</View>
{/* Member Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<InfoRow label="E-Mail" value={member.email} />
{member.telefon && <InfoRow label="Telefon" value={member.telefon} />}
<InfoRow label="Sparte" value={member.sparte} />
<InfoRow label="Ort" value={member.ort} />
{member.seit && <InfoRow label="Mitglied seit" value={String(member.seit)} />}
<View style={[styles.statusBadge, styles.verifyBadge]}>
<Text style={[styles.statusBadgeText, styles.verifyBadgeText]}>Verifiziert</Text>
</View>
<View className="mx-4 mt-2">
<Text className="text-xs text-gray-400 px-1">
Änderungen an Ihren Daten nehmen Sie über die Innungsgeschäftsstelle vor.
</Text>
</View>
</>
) : null}
{/* Links */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<TouchableOpacity
onPress={() => Linking.openURL('https://innungsapp.de/datenschutz')}
className="flex-row items-center justify-between px-4 py-3.5 border-b border-gray-50"
>
<Text className="text-gray-700">Datenschutzerklärung</Text>
<Text className="text-gray-400"></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => Linking.openURL('https://innungsapp.de/impressum')}
className="flex-row items-center justify-between px-4 py-3.5"
>
<Text className="text-gray-700">Impressum</Text>
<Text className="text-gray-400"></Text>
</TouchableOpacity>
</View>
</View>
{/* Logout */}
<View className="mx-4 mt-4">
<TouchableOpacity
onPress={signOut}
className="bg-red-50 border border-red-200 rounded-2xl py-4 items-center"
>
<Text className="text-red-600 font-semibold">Abmelden</Text>
</TouchableOpacity>
<Text style={styles.sectionTitle}>Mein Account</Text>
<View style={styles.menuCard}>
{MENU_ITEMS.map((item, index) => (
<TouchableOpacity
key={item.label}
style={[styles.menuRow, index < MENU_ITEMS.length - 1 && styles.menuRowBorder]}
activeOpacity={0.82}
onPress={openPlaceholder}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name={item.icon} size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>{item.label}</Text>
</View>
<View style={styles.menuRight}>
{item.badge ? (
<View style={[styles.rowBadge, item.badge === 'Aktiv' ? styles.rowBadgeActive : styles.rowBadgeAlert]}>
<Text style={styles.rowBadgeText}>{item.badge}</Text>
</View>
) : null}
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
</View>
</TouchableOpacity>
))}
</View>
<View className="h-8" />
<Text style={styles.sectionTitle}>Unterstuetzung</Text>
<View style={styles.supportCard}>
<Text style={styles.supportTitle}>Probleme oder Fragen?</Text>
<Text style={styles.supportText}>
Unser Support-Team hilft Ihnen gerne bei technischen Schwierigkeiten weiter.
</Text>
<TouchableOpacity style={styles.supportBtn} activeOpacity={0.84} onPress={openPlaceholder}>
<Text style={styles.supportBtnText}>Support kontaktieren</Text>
</TouchableOpacity>
<Ionicons name="help-circle-outline" size={70} color="rgba(255,255,255,0.12)" style={styles.supportIcon} />
</View>
<TouchableOpacity style={styles.logoutBtn} activeOpacity={0.84} onPress={() => void signOut()}>
<Ionicons name="log-out-outline" size={20} color="#B91C1C" />
<Text style={styles.logoutText}>Abmelden</Text>
</TouchableOpacity>
<Text style={styles.footer}>InnungsApp Version 2.4.0</Text>
</ScrollView>
</SafeAreaView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-28">{label}</Text>
<Text className="text-sm text-gray-900 flex-1">{value}</Text>
</View>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
content: {
paddingHorizontal: 18,
paddingBottom: 30,
gap: 14,
},
hero: {
backgroundColor: '#FFFFFF',
alignItems: 'center',
paddingTop: 24,
paddingBottom: 18,
borderRadius: 22,
borderWidth: 1,
borderColor: '#E2E8F0',
marginTop: 8,
},
avatarWrap: {
position: 'relative',
},
avatarText: {
width: 94,
height: 94,
borderRadius: 47,
backgroundColor: '#DBEAFE',
borderWidth: 4,
borderColor: '#FFFFFF',
overflow: 'hidden',
textAlign: 'center',
textAlignVertical: 'center',
color: '#003B7E',
fontSize: 34,
fontWeight: '800',
includeFontPadding: false,
},
settingsBtn: {
position: 'absolute',
right: 0,
bottom: 2,
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: '#E2E8F0',
alignItems: 'center',
justifyContent: 'center',
},
name: {
marginTop: 14,
fontSize: 24,
fontWeight: '800',
color: '#0F172A',
},
role: {
marginTop: 2,
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.5,
color: '#64748B',
textTransform: 'uppercase',
},
badgesRow: {
marginTop: 10,
flexDirection: 'row',
gap: 8,
},
statusBadge: {
backgroundColor: '#DCFCE7',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
},
statusBadgeText: {
color: '#166534',
fontSize: 11,
fontWeight: '700',
},
verifyBadge: {
backgroundColor: '#DBEAFE',
},
verifyBadgeText: {
color: '#1D4ED8',
},
sectionTitle: {
marginTop: 2,
paddingLeft: 2,
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: 1.1,
color: '#94A3B8',
fontWeight: '800',
},
menuCard: {
backgroundColor: '#FFFFFF',
borderRadius: 18,
borderWidth: 1,
borderColor: '#E2E8F0',
overflow: 'hidden',
},
menuRow: {
paddingHorizontal: 14,
paddingVertical: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
menuRowBorder: {
borderBottomWidth: 1,
borderBottomColor: '#F1F5F9',
},
menuLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
menuIcon: {
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
},
menuLabel: {
fontSize: 14,
fontWeight: '700',
color: '#1E293B',
},
menuRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 7,
},
rowBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
},
rowBadgeActive: {
backgroundColor: '#DCFCE7',
},
rowBadgeAlert: {
backgroundColor: '#EF4444',
},
rowBadgeText: {
fontSize: 10,
fontWeight: '700',
color: '#FFFFFF',
},
supportCard: {
borderRadius: 18,
backgroundColor: '#003B7E',
padding: 16,
overflow: 'hidden',
position: 'relative',
},
supportTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '800',
marginBottom: 4,
maxWidth: 180,
},
supportText: {
color: '#BFDBFE',
fontSize: 12,
lineHeight: 18,
marginBottom: 12,
maxWidth: 240,
},
supportBtn: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(255,255,255,0.15)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.25)',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
},
supportBtnText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.6,
},
supportIcon: {
position: 'absolute',
right: -10,
bottom: -12,
},
logoutBtn: {
marginTop: 4,
backgroundColor: '#FEF2F2',
borderRadius: 14,
borderWidth: 1,
borderColor: '#FECACA',
paddingVertical: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
logoutText: {
color: '#B91C1C',
fontSize: 14,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
footer: {
textAlign: 'center',
marginTop: 4,
fontSize: 10,
fontWeight: '700',
letterSpacing: 1,
color: '#94A3B8',
textTransform: 'uppercase',
},
})

View File

@@ -1,94 +1,335 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, StyleSheet, Platform
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import { useStelleDetail } from '@/hooks/useStellen'
import { Button } from '@/components/ui/Button'
const SPARTE_COLOR: Record<string, string> = {
elektro: '#1D4ED8', sanitär: '#0E7490', it: '#7C3AED',
info: '#7C3AED', heizung: '#D97706', maler: '#059669',
}
function getSparteColor(sparte: string): string {
const lower = sparte.toLowerCase()
for (const [k, v] of Object.entries(SPARTE_COLOR)) {
if (lower.includes(k)) return v
}
return '#003B7E'
}
export default function StelleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: stelle, isLoading } = trpc.stellen.byId.useQuery({ id })
const { data: stelle, isLoading } = useStelleDetail(id)
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#003B7E" />
</View>
)
}
if (!stelle) return null
const betreffVorlage = `Bewerbung als Auszubildender bei ${stelle.member.betrieb}`
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(betreffVorlage)}`
const color = getSparteColor(stelle.sparte)
const initial = stelle.sparte.charAt(0).toUpperCase()
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(`Bewerbung als Auszubildender bei ${stelle.member.betrieb}`)}`
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
</View>
<ScrollView>
<>
<Stack.Screen options={{ headerShown: false }} />
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-6 border-b border-gray-100">
<View className="bg-brand-50 rounded-xl w-16 h-16 items-center justify-center mb-4">
<Text className="text-3xl">🎓</Text>
</View>
<Text className="text-2xl font-bold text-gray-900">{stelle.member.betrieb}</Text>
<Text className="text-gray-500 mt-1">{stelle.member.ort}</Text>
<Text className="text-gray-500">{stelle.org.name}</Text>
</View>
{/* Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<DetailRow label="Sparte" value={stelle.sparte} />
<DetailRow label="Anzahl Stellen" value={String(stelle.stellenAnz)} />
{stelle.lehrjahr && <DetailRow label="Lehrjahr" value={stelle.lehrjahr} />}
{stelle.verguetung && <DetailRow label="Vergütung" value={stelle.verguetung} />}
</View>
{stelle.beschreibung && (
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
<Text className="font-semibold text-gray-900 mb-2">Über die Stelle</Text>
<Text className="text-gray-600 leading-6">{stelle.beschreibung}</Text>
</View>
)}
{/* CTA */}
<View className="mx-4 mt-6">
<TouchableOpacity
onPress={() => Linking.openURL(bewerbungsUrl)}
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-white text-xl"></Text>
<Text className="text-white font-semibold text-base">Jetzt bewerben</Text>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color="#0F172A" />
</TouchableOpacity>
<View style={styles.headerSpacer} />
<TouchableOpacity style={styles.shareButton}>
<Ionicons name="share-outline" size={24} color="#0F172A" />
</TouchableOpacity>
{stelle.kontaktName && (
<Text className="text-center text-sm text-gray-400 mt-3">
Ansprechperson: {stelle.kontaktName}
</Text>
)}
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* Header Card */}
<View style={styles.heroCard}>
<View style={[styles.logoBox, { backgroundColor: color + '15' }]}>
<Text style={[styles.logoText, { color }]}>{initial}</Text>
</View>
<Text style={styles.jobTitle}>Auszubildender {stelle.sparte}</Text>
<Text style={styles.companyName}>{stelle.member.betrieb}</Text>
<View style={styles.locationBadge}>
<Ionicons name="location-sharp" size={14} color="#64748B" />
<Text style={styles.locationText}>
{stelle.member.ort} · {stelle.org.name}
</Text>
</View>
</View>
{/* Key Facts */}
<Text style={styles.sectionHeader}>Eckdaten</Text>
<View style={styles.factsContainer}>
<View style={styles.factItem}>
<View style={styles.factIconBox}>
<Ionicons name="people-outline" size={20} color="#003B7E" />
</View>
<View>
<Text style={styles.factLabel}>Anzahl Stellen</Text>
<Text style={styles.factValue}>{stelle.stellenAnz}</Text>
</View>
</View>
{stelle.lehrjahr && (
<View style={styles.factItem}>
<View style={styles.factIconBox}>
<Ionicons name="school-outline" size={20} color="#003B7E" />
</View>
<View>
<Text style={styles.factLabel}>Lehrjahr</Text>
<Text style={styles.factValue}>{stelle.lehrjahr}</Text>
</View>
</View>
)}
{stelle.verguetung && (
<View style={styles.factItem}>
<View style={styles.factIconBox}>
<Ionicons name="cash-outline" size={20} color="#003B7E" />
</View>
<View>
<Text style={styles.factLabel}>Vergütung</Text>
<Text style={styles.factValue}>{stelle.verguetung}</Text>
</View>
</View>
)}
</View>
{/* Description */}
{stelle.beschreibung && (
<View style={styles.section}>
<Text style={styles.sectionHeader}>Beschreibung</Text>
<Text style={styles.description}>{stelle.beschreibung}</Text>
</View>
)}
{/* Contact */}
{stelle.kontaktName && (
<View style={styles.contactBox}>
<Text style={styles.contactTitle}>Ansprechpartner</Text>
<View style={styles.contactRow}>
<View style={styles.avatarPlaceholder}>
<Text style={styles.avatarInitials}>{stelle.kontaktName.charAt(0)}</Text>
</View>
<View>
<Text style={styles.contactName}>{stelle.kontaktName}</Text>
<Text style={styles.contactRole}>Recruiting</Text>
</View>
</View>
</View>
)}
</ScrollView>
{/* Footer */}
<View style={styles.footer}>
<Button label="Jetzt bewerben" onPress={() => Linking.openURL(bewerbungsUrl)} />
</View>
</SafeAreaView>
</>
)
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-32">{label}</Text>
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8FAFC',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
paddingHorizontal: 16,
paddingVertical: 12,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F8FAFC',
},
backButton: {
padding: 8,
borderRadius: 12,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
headerSpacer: {
flex: 1,
},
shareButton: {
padding: 8,
},
scrollContent: {
padding: 24,
paddingBottom: 40,
},
heroCard: {
alignItems: 'center',
marginBottom: 32,
},
logoBox: {
width: 80,
height: 80,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
logoText: {
fontSize: 32,
fontWeight: '800',
},
jobTitle: {
fontSize: 24,
fontWeight: '800',
color: '#0F172A',
textAlign: 'center',
marginBottom: 8,
letterSpacing: -0.5,
},
companyName: {
fontSize: 16,
fontWeight: '600',
color: '#64748B',
marginBottom: 16,
},
locationBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F1F5F9',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 99,
gap: 6,
},
locationText: {
fontSize: 13,
color: '#475569',
fontWeight: '500',
},
sectionHeader: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 16,
},
factsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginBottom: 32,
},
factItem: {
flex: 1,
minWidth: '45%',
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
shadowColor: '#64748B',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 1,
},
factIconBox: {
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: '#EFF6FF',
justifyContent: 'center',
alignItems: 'center',
},
factLabel: {
fontSize: 11,
color: '#94A3B8',
fontWeight: '600',
textTransform: 'uppercase',
marginBottom: 2,
},
factValue: {
fontSize: 14,
fontWeight: '700',
color: '#0F172A',
},
section: {
marginBottom: 32,
},
description: {
fontSize: 16,
lineHeight: 26,
color: '#334155',
},
contactBox: {
backgroundColor: '#FFFFFF',
padding: 20,
borderRadius: 20,
marginBottom: 32,
},
contactTitle: {
fontSize: 14,
fontWeight: '700',
color: '#94A3B8',
textTransform: 'uppercase',
marginBottom: 16,
letterSpacing: 0.5,
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatarPlaceholder: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#F1F5F9',
justifyContent: 'center',
alignItems: 'center',
},
avatarInitials: {
fontSize: 18,
fontWeight: '700',
color: '#64748B',
},
contactName: {
fontSize: 16,
fontWeight: '700',
color: '#0F172A',
},
contactRole: {
fontSize: 13,
color: '#64748B',
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#FFFFFF',
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 32 : 20,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
},
})

View File

@@ -1,70 +1,60 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { View, Text, FlatList, RefreshControl, StyleSheet } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { useStellenListe } from '@/hooks/useStellen'
import { StelleCard } from '@/components/stellen/StelleCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { useAuth } from '@/hooks/useAuth'
export default function StellenScreen() {
const router = useRouter()
const { isAuthenticated } = useAuth()
const { data, isLoading, refetch, isRefetching } = trpc.stellen.listPublic.useQuery({})
const { data, isLoading, refetch, isRefetching } = useStellenListe()
const totalStellen = data?.reduce((sum, s) => sum + s.stellenAnz, 0) ?? 0
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* Header */}
<View className="bg-white px-4 pt-3 pb-3 border-b border-gray-100">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-xl font-bold text-gray-900">Lehrlingsbörse</Text>
<Text className="text-sm text-gray-500 mt-0.5">
{data?.length ?? 0} Angebote
</Text>
<View style={styles.header}>
<View style={styles.titleRow}>
<View style={styles.titleBlock}>
<Text style={styles.screenTitle}>Lehrlingsbörse</Text>
<Text style={styles.subtitle}>Ausbildungsplätze in deiner Innung</Text>
</View>
{isAuthenticated && (
<TouchableOpacity
onPress={() => router.push('/(app)/stellen/neu')}
className="bg-brand-500 px-4 py-2 rounded-xl"
>
<Text className="text-white text-sm font-medium">+ Stelle anbieten</Text>
</TouchableOpacity>
{/* Counter */}
{totalStellen > 0 && (
<View style={styles.counter}>
<Text style={styles.counterNumber}>{totalStellen}</Text>
<Text style={styles.counterLabel}>Stellen</Text>
</View>
)}
</View>
</View>
<View style={styles.divider} />
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
}
renderItem={({ item }) => (
<StelleCard
stelle={item}
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
onPress={() => router.push(`/(app)/stellen/${item.id}` as never)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="🎓"
icon="school-outline"
title="Keine Angebote"
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
subtitle="Aktuell keine Ausbildungsplätze verfügbar"
/>
}
/>
@@ -72,3 +62,69 @@ export default function StellenScreen() {
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
header: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingTop: 18,
paddingBottom: 16,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
titleBlock: {
flex: 1,
},
screenTitle: {
fontSize: 26,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.5,
},
subtitle: {
fontSize: 13,
color: '#64748B',
marginTop: 2,
},
counter: {
backgroundColor: '#003B7E',
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 8,
alignItems: 'center',
minWidth: 56,
shadowColor: '#003B7E',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
},
counterNumber: {
color: '#FFFFFF',
fontSize: 22,
fontWeight: '800',
lineHeight: 24,
},
counterLabel: {
color: 'rgba(255,255,255,0.75)',
fontSize: 9,
fontWeight: '600',
letterSpacing: 0.5,
},
divider: {
height: 1,
backgroundColor: '#E2E8F0',
},
list: {
padding: 16,
gap: 10,
},
})

View File

@@ -1,152 +1,379 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
Alert,
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, Alert, StyleSheet, Platform,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import { useTerminDetail, useToggleAnmeldung } from '@/hooks/useTermine'
import { AnmeldeButton } from '@/components/termine/AnmeldeButton'
import { Badge } from '@/components/ui/Badge'
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
import { TERMIN_TYP_LABELS } from '@innungsapp/shared/types'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import * as Calendar from 'expo-calendar'
export default function TerminDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: termin, isLoading } = trpc.termine.byId.useQuery({ id })
const toggleMutation = trpc.termine.toggleAnmeldung.useMutation({
onSuccess: () => {
// Invalidate queries
},
})
async function addToCalendar() {
if (!termin) return
const { status } = await Calendar.requestCalendarPermissionsAsync()
if (status !== 'granted') {
Alert.alert('Keine Berechtigung', 'Bitte erlauben Sie den Kalender-Zugriff in den Einstellungen.')
return
}
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT)
const defaultCal = calendars.find((c) => c.isPrimary) ?? calendars[0]
if (!defaultCal) {
Alert.alert('Kein Kalender', 'Es wurde kein Kalender gefunden.')
return
}
const startDate = new Date(termin.datum)
if (termin.uhrzeit) {
const [h, m] = termin.uhrzeit.split(':').map(Number)
startDate.setHours(h, m)
}
await Calendar.createEventAsync(defaultCal.id, {
title: termin.titel,
startDate,
endDate: startDate,
location: termin.adresse ?? termin.ort ?? undefined,
notes: termin.beschreibung ?? undefined,
})
Alert.alert('Termin gespeichert', 'Der Termin wurde in Ihren Kalender eingetragen.')
}
const { data: termin, isLoading } = useTerminDetail(id)
const { mutate, isPending } = useToggleAnmeldung()
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#003B7E" />
</View>
)
}
if (!termin) return null
const datumFormatted = format(new Date(termin.datum), 'EEEE, dd. MMMM yyyy', { locale: de })
const datum = new Date(termin.datum)
const isPast = datum < new Date()
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
</View>
<ScrollView>
<View className="bg-white px-4 py-6 border-b border-gray-100">
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
<Text className="text-2xl font-bold text-gray-900 mt-3">{termin.titel}</Text>
<Text className="text-gray-500 mt-2 capitalize">{datumFormatted}</Text>
{termin.uhrzeit && (
<Text className="text-gray-500">
{termin.uhrzeit}{termin.endeUhrzeit ? ` ${termin.endeUhrzeit}` : ''} Uhr
</Text>
)}
</View>
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
{termin.ort && (
<TouchableOpacity
onPress={() =>
termin.adresse &&
Linking.openURL(
`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`
)
}
className="flex-row items-center px-4 py-3 border-b border-gray-50"
>
<Text className="text-2xl mr-3">📍</Text>
<View>
<Text className="font-medium text-gray-900">{termin.ort}</Text>
{termin.adresse && (
<Text className="text-sm text-brand-500 mt-0.5">{termin.adresse}</Text>
)}
</View>
</TouchableOpacity>
)}
<View className="flex-row items-center px-4 py-3">
<Text className="text-2xl mr-3">👥</Text>
<Text className="text-gray-700">
{termin.teilnehmerAnzahl} Anmeldungen
{termin.maxTeilnehmer ? ` / ${termin.maxTeilnehmer} Plätze` : ''}
</Text>
</View>
</View>
{termin.beschreibung && (
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
<Text className="font-semibold text-gray-900 mb-2">Beschreibung</Text>
<Text className="text-gray-600 leading-6">{termin.beschreibung}</Text>
</View>
)}
{/* Actions */}
<View className="mx-4 mt-4 gap-3">
<AnmeldeButton
terminId={id}
isAngemeldet={termin.isAngemeldet}
onToggle={() => toggleMutation.mutate({ terminId: id })}
isLoading={toggleMutation.isPending}
/>
<TouchableOpacity
onPress={addToCalendar}
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-gray-900 text-xl">📅</Text>
<Text className="text-gray-900 font-semibold">Zum Kalender hinzufügen</Text>
<>
<Stack.Screen options={{ headerShown: false }} />
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color="#0F172A" />
</TouchableOpacity>
<View style={styles.headerSpacer} />
<TouchableOpacity style={styles.shareButton}>
<Ionicons name="share-outline" size={24} color="#0F172A" />
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* Date Badge */}
<View style={styles.dateline}>
<View style={styles.calendarIcon}>
<Ionicons name="calendar" size={18} color="#003B7E" />
</View>
<Text style={styles.dateText}>
{format(datum, 'EEEE, d. MMMM yyyy', { locale: de })}
</Text>
</View>
<Text style={styles.title}>{termin.titel}</Text>
<View style={styles.badgesRow}>
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
{termin.isAngemeldet && (
<View style={styles.registeredBadge}>
<Ionicons name="checkmark-circle" size={14} color="#059669" />
<Text style={styles.registeredText}>Angemeldet</Text>
</View>
)}
</View>
{/* Info Card */}
<View style={styles.card}>
{/* Time */}
{termin.uhrzeit && (
<View style={styles.infoRow}>
<View style={styles.iconBox}>
<Ionicons name="time-outline" size={22} color="#64748B" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Uhrzeit</Text>
<Text style={styles.infoValue}>
{termin.uhrzeit}
{termin.endeUhrzeit ? ` ${termin.endeUhrzeit}` : ''} Uhr
</Text>
</View>
</View>
)}
{termin.uhrzeit && termin.ort && <View style={styles.divider} />}
{/* Location */}
{termin.ort && (
<TouchableOpacity
style={styles.infoRow}
onPress={() =>
termin.adresse &&
Linking.openURL(`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`)
}
activeOpacity={termin.adresse ? 0.7 : 1}
>
<View style={styles.iconBox}>
<Ionicons name="location-outline" size={22} color="#64748B" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Ort</Text>
<Text style={styles.infoValue}>{termin.ort}</Text>
{termin.adresse && (
<Text style={styles.infoSub}>{termin.adresse}</Text>
)}
</View>
{termin.adresse && (
<Ionicons name="chevron-forward" size={20} color="#CBD5E1" />
)}
</TouchableOpacity>
)}
{(termin.uhrzeit || termin.ort) && (termin.teilnehmerAnzahl !== undefined) && <View style={styles.divider} />}
{/* Participants */}
<View style={styles.infoRow}>
<View style={styles.iconBox}>
<Ionicons name="people-outline" size={22} color="#64748B" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Teilnehmer</Text>
<Text style={styles.infoValue}>
{termin.teilnehmerAnzahl} Anmeldungen
</Text>
{termin.maxTeilnehmer && (
<Text style={styles.infoSub}>{termin.maxTeilnehmer} Plätze verfügbar</Text>
)}
</View>
</View>
</View>
{/* Description */}
{termin.beschreibung && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Beschreibung</Text>
<Text style={styles.description}>{termin.beschreibung}</Text>
</View>
)}
</ScrollView>
{/* Bottom Action Bar only for upcoming events */}
{isPast ? (
<View style={styles.pastBar}>
<Ionicons name="time-outline" size={16} color="#94A3B8" />
<Text style={styles.pastBarText}>Vergangener Termin keine Anmeldung möglich</Text>
</View>
) : (
<View style={styles.actionBar}>
<View style={styles.actionButtonContainer}>
<AnmeldeButton
terminId={id}
isAngemeldet={termin.isAngemeldet}
onToggle={() => mutate({ terminId: id })}
isLoading={isPending}
/>
</View>
<TouchableOpacity
style={styles.calendarButton}
onPress={() => Alert.alert('Kalender', 'Funktion folgt in Kürze')}
>
<Ionicons name="calendar-outline" size={24} color="#0F172A" />
</TouchableOpacity>
</View>
)}
</SafeAreaView>
</>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8FAFC',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
paddingHorizontal: 16,
paddingVertical: 12,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F8FAFC',
},
backButton: {
padding: 8,
borderRadius: 12,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
headerSpacer: {
flex: 1,
},
shareButton: {
padding: 8,
},
scrollContent: {
padding: 24,
paddingBottom: 100,
},
dateline: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
gap: 8,
},
calendarIcon: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: '#EFF6FF',
justifyContent: 'center',
alignItems: 'center',
},
dateText: {
fontSize: 15,
fontWeight: '600',
color: '#003B7E',
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#0F172A',
lineHeight: 34,
marginBottom: 16,
letterSpacing: -0.5,
},
badgesRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginBottom: 32,
},
registeredBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 4,
backgroundColor: '#ECFDF5',
borderRadius: 6,
borderWidth: 1,
borderColor: '#A7F3D0',
},
registeredText: {
fontSize: 12,
fontWeight: '600',
color: '#047857',
},
card: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 20,
shadowColor: '#64748B',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 12,
elevation: 2,
marginBottom: 32,
},
infoRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 16,
},
iconBox: {
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: '#F1F5F9',
justifyContent: 'center',
alignItems: 'center',
},
infoContent: {
flex: 1,
paddingTop: 2,
},
infoLabel: {
fontSize: 12,
fontWeight: '600',
color: '#94A3B8',
marginBottom: 2,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
infoValue: {
fontSize: 16,
fontWeight: '600',
color: '#0F172A',
lineHeight: 22,
},
infoSub: {
fontSize: 14,
color: '#64748B',
marginTop: 2,
lineHeight: 20,
},
divider: {
height: 1,
backgroundColor: '#F1F5F9',
marginVertical: 16,
marginLeft: 56,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 12,
},
description: {
fontSize: 16,
lineHeight: 26,
color: '#334155',
},
actionBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingVertical: 16,
paddingBottom: Platform.OS === 'ios' ? 32 : 16,
flexDirection: 'row',
gap: 12,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.02,
shadowRadius: 8,
elevation: 4,
},
pastBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#F8FAFC',
paddingHorizontal: 20,
paddingVertical: 14,
paddingBottom: Platform.OS === 'ios' ? 28 : 14,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderTopWidth: 1,
borderTopColor: '#E2E8F0',
},
pastBarText: {
fontSize: 13,
color: '#94A3B8',
fontWeight: '500',
},
actionButtonContainer: {
flex: 1,
},
calendarButton: {
width: 52,
height: 52,
borderRadius: 14,
backgroundColor: '#F1F5F9',
justifyContent: 'center',
alignItems: 'center',
},
})

View File

@@ -1,78 +1,78 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
View, Text, FlatList, TouchableOpacity, RefreshControl, StyleSheet,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { useTermineListe, useToggleAnmeldung } from '@/hooks/useTermine'
import { TerminCard } from '@/components/termine/TerminCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
type Tab = 'kommend' | 'vergangen'
export default function TermineScreen() {
const router = useRouter()
const [tab, setTab] = useState<'kommend' | 'vergangen'>('kommend')
const { data, isLoading, refetch, isRefetching } = trpc.termine.list.useQuery({
upcoming: tab === 'kommend',
})
const [tab, setTab] = useState<Tab>('kommend')
const { data, isLoading, refetch, isRefetching } = useTermineListe(tab === 'kommend')
const { mutate, isPending } = useToggleAnmeldung()
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900 mb-3">Termine</Text>
{/* Tabs */}
<View className="flex-row gap-1 bg-gray-100 p-1 rounded-xl">
{(['kommend', 'vergangen'] as const).map((t) => (
<TouchableOpacity
key={t}
onPress={() => setTab(t)}
className={`flex-1 py-2 rounded-lg items-center ${
tab === t ? 'bg-white shadow-sm' : ''
}`}
>
<Text
className={`text-sm font-medium ${
tab === t ? 'text-gray-900' : 'text-gray-500'
}`}
<View style={styles.header}>
<Text style={styles.screenTitle}>Termine</Text>
{/* Segment control */}
<View style={styles.segmentContainer}>
{(['kommend', 'vergangen'] as Tab[]).map((t) => {
const active = tab === t
return (
<TouchableOpacity
key={t}
onPress={() => setTab(t)}
style={[styles.segment, active && styles.segmentActive]}
activeOpacity={0.8}
>
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
</Text>
</TouchableOpacity>
))}
<Text style={[styles.segmentLabel, active && styles.segmentLabelActive]}>
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
</Text>
</TouchableOpacity>
)
})}
</View>
</View>
<View style={styles.divider} />
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
}
renderItem={({ item }) => (
<TerminCard
termin={item}
onPress={() => router.push(`/(app)/termine/${item.id}`)}
isPast={tab === 'vergangen'}
onPress={() => router.push(`/(app)/termine/${item.id}` as never)}
onToggleAnmeldung={() => mutate({ terminId: item.id })}
isToggling={isPending}
/>
)}
ListEmptyComponent={
<EmptyState
icon="📅"
title={tab === 'kommend' ? 'Keine Termine' : 'Keine vergangenen Termine'}
subtitle="Es sind aktuell keine Termine eingetragen"
icon="calendar-outline"
title="Keine Termine"
subtitle={
tab === 'kommend'
? 'Aktuell keine bevorstehenden Termine'
: 'Keine vergangenen Termine'
}
/>
}
/>
@@ -80,3 +80,61 @@ export default function TermineScreen() {
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
header: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingTop: 18,
paddingBottom: 14,
},
screenTitle: {
fontSize: 28,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.5,
marginBottom: 14,
},
segmentContainer: {
flexDirection: 'row',
backgroundColor: '#F4F4F5',
borderRadius: 14,
padding: 3,
gap: 3,
},
segment: {
flex: 1,
paddingVertical: 9,
borderRadius: 11,
alignItems: 'center',
},
segmentActive: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 2,
},
segmentLabel: {
fontSize: 13,
fontWeight: '500',
color: '#64748B',
},
segmentLabelActive: {
color: '#0F172A',
fontWeight: '600',
},
divider: {
height: 1,
backgroundColor: '#E2E8F0',
},
list: {
padding: 16,
gap: 10,
},
})

View File

@@ -1,44 +1,106 @@
import { View, Text, TouchableOpacity } from 'react-native'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
export default function CheckEmailScreen() {
const router = useRouter()
const { email } = useLocalSearchParams<{ email: string }>()
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 justify-center items-center px-6">
{/* Envelope Illustration */}
<View className="w-24 h-24 bg-brand-50 rounded-full items-center justify-center mb-6">
<Text className="text-5xl">📧</Text>
<SafeAreaView style={styles.safeArea}>
<View style={styles.content}>
<View style={styles.iconBox}>
<Ionicons name="checkmark-circle" size={46} color="#15803D" />
</View>
<Text className="text-2xl font-bold text-gray-900 text-center mb-3">
Schau in dein Postfach
</Text>
<Text className="text-gray-500 text-center leading-6 mb-2">
Wir haben einen Login-Link an
</Text>
<Text className="font-semibold text-gray-900 text-center mb-6">
{email}
</Text>
<Text className="text-gray-500 text-center leading-6">
Klicken Sie auf den Link in der E-Mail, um sich einzuloggen.
Der Link ist 24 Stunden gültig.
<Text style={styles.title}>E-Mail pruefen</Text>
<Text style={styles.body}>Wir haben einen Login-Link gesendet an:</Text>
<View style={styles.emailBox}>
<Text style={styles.emailText} numberOfLines={1}>{email}</Text>
</View>
<Text style={styles.hint}>
Bitte oeffnen Sie den Link in Ihrer E-Mail, um sich anzumelden.
</Text>
<View className="mt-10 space-y-3 w-full">
<TouchableOpacity
onPress={() => router.back()}
className="py-3 items-center"
>
<Text className="text-brand-500 font-medium">
Andere E-Mail verwenden
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backBtn}
activeOpacity={0.8}
>
<Ionicons name="chevron-back" size={16} color="#003B7E" />
<Text style={styles.backText}>Andere E-Mail verwenden</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#FFFFFF',
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 28,
},
iconBox: {
width: 88,
height: 88,
backgroundColor: '#DCFCE7',
borderRadius: 44,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
title: {
fontSize: 24,
fontWeight: '800',
color: '#0F172A',
textAlign: 'center',
marginBottom: 10,
},
body: {
fontSize: 14,
color: '#64748B',
textAlign: 'center',
marginBottom: 6,
},
emailBox: {
backgroundColor: '#F8FAFC',
borderWidth: 1,
borderColor: '#E2E8F0',
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 10,
marginBottom: 16,
},
emailText: {
fontSize: 15,
fontWeight: '700',
color: '#0F172A',
},
hint: {
fontSize: 13,
color: '#64748B',
textAlign: 'center',
lineHeight: 19,
paddingHorizontal: 8,
},
backBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 28,
},
backText: {
fontSize: 14,
fontWeight: '700',
color: '#003B7E',
},
})

View File

@@ -1,16 +1,12 @@
import {
View,
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
View, Text, TextInput, TouchableOpacity,
KeyboardAvoidingView, Platform, ActivityIndicator, StyleSheet,
} from 'react-native'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { authClient } from '@/lib/auth-client'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { authClient } from '@/lib/auth-client'
export default function LoginScreen() {
const router = useRouter()
@@ -18,16 +14,16 @@ export default function LoginScreen() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const canSubmit = email.trim().length > 0 && !loading
async function handleSendLink() {
if (!email.trim()) return
setLoading(true)
setError('')
const result = await authClient.signIn.magicLink({
email: email.trim().toLowerCase(),
callbackURL: '/news',
callbackURL: '/home',
})
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
@@ -37,33 +33,28 @@ export default function LoginScreen() {
}
return (
<SafeAreaView className="flex-1 bg-white">
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
style={styles.keyboardView}
>
<View className="flex-1 justify-center px-6">
{/* Logo */}
<View className="items-center mb-10">
<View className="w-20 h-20 bg-brand-500 rounded-2xl items-center justify-center mb-4">
<Text className="text-white font-bold text-3xl">I</Text>
<View style={styles.content}>
<View style={styles.logoSection}>
<View style={styles.logoBox}>
<Text style={styles.logoLetter}>I</Text>
</View>
<Text className="text-2xl font-bold text-gray-900">InnungsApp</Text>
<Text className="text-gray-500 mt-1 text-center">
Die digitale Plattform Ihrer Innung
</Text>
<Text style={styles.appName}>InnungsApp</Text>
<Text style={styles.tagline}>Ihre Kreishandwerkerschaft digital</Text>
</View>
{/* Form */}
<View className="space-y-4">
<View>
<Text className="text-sm font-medium text-gray-700 mb-2">
E-Mail-Adresse
</Text>
<View style={styles.form}>
<Text style={styles.inputLabel}>Email-Adresse</Text>
<View style={styles.inputWrap}>
<Ionicons name="mail-outline" size={18} color="#94A3B8" />
<TextInput
className="border border-gray-300 rounded-xl px-4 py-3.5 text-base text-gray-900 bg-gray-50"
placeholder="ihre@email.de"
placeholderTextColor="#9ca3af"
style={styles.input}
placeholder="beispiel@handwerk.de"
placeholderTextColor="#94A3B8"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
@@ -75,37 +66,141 @@ export default function LoginScreen() {
</View>
{error ? (
<View className="bg-red-50 rounded-xl px-4 py-3">
<Text className="text-red-600 text-sm">{error}</Text>
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
<TouchableOpacity
onPress={handleSendLink}
disabled={loading || !email.trim()}
className={`rounded-xl py-4 items-center ${
loading || !email.trim() ? 'bg-gray-200' : 'bg-brand-500'
}`}
disabled={!canSubmit}
style={[styles.submitBtn, !canSubmit && styles.submitBtnDisabled]}
activeOpacity={0.85}
>
{loading ? (
<ActivityIndicator color="white" />
<ActivityIndicator color="#FFFFFF" />
) : (
<Text
className={`font-semibold text-base ${
loading || !email.trim() ? 'text-gray-400' : 'text-white'
}`}
>
Magic Link senden
</Text>
<View style={styles.submitContent}>
<Text style={styles.submitLabel}>Login-Link senden</Text>
<Ionicons name="arrow-forward" size={16} color="#FFFFFF" />
</View>
)}
</TouchableOpacity>
<Text className="text-center text-sm text-gray-400">
Kein Passwort nötig Zugang nur per Admin-Einladung
</Text>
</View>
<Text style={styles.hint}>
Noch kein Zugang? Kontaktieren Sie Ihre Innungsgeschaeftsstelle.
</Text>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#FFFFFF',
},
keyboardView: {
flex: 1,
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 24,
},
logoSection: {
alignItems: 'center',
marginBottom: 40,
},
logoBox: {
width: 64,
height: 64,
backgroundColor: '#003B7E',
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
logoLetter: {
color: '#FFFFFF',
fontSize: 30,
fontWeight: '900',
},
appName: {
fontSize: 30,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.6,
marginBottom: 4,
},
tagline: {
fontSize: 14,
color: '#64748B',
textAlign: 'center',
},
form: {
gap: 12,
},
inputLabel: {
fontSize: 14,
fontWeight: '700',
color: '#334155',
},
inputWrap: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F8FAFC',
borderRadius: 14,
borderWidth: 1,
borderColor: '#E2E8F0',
paddingHorizontal: 12,
gap: 8,
},
input: {
flex: 1,
paddingVertical: 13,
color: '#0F172A',
fontSize: 15,
},
errorBox: {
backgroundColor: '#FEF2F2',
borderWidth: 1,
borderColor: '#FECACA',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 10,
},
errorText: {
color: '#B91C1C',
fontSize: 13,
},
submitBtn: {
backgroundColor: '#003B7E',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
marginTop: 4,
},
submitBtnDisabled: {
backgroundColor: '#CBD5E1',
},
submitContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
submitLabel: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 15,
},
hint: {
marginTop: 24,
textAlign: 'center',
color: '#64748B',
fontSize: 13,
lineHeight: 18,
},
})

View File

@@ -1,12 +1,7 @@
import '../global.css'
import { useEffect } from 'react'
import { Stack } from 'expo-router'
import { SplashScreen } from 'expo-router'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/trpc'
import { TRPCProvider } from '@/lib/trpc'
import { Stack, SplashScreen } from 'expo-router'
import { useAuthStore } from '@/store/auth.store'
import { setupPushNotifications } from '@/lib/notifications'
SplashScreen.preventAutoHideAsync()
@@ -18,20 +13,14 @@ export default function RootLayout() {
initAuth().finally(() => SplashScreen.hideAsync())
}, [initAuth])
useEffect(() => {
setupPushNotifications().catch(console.error)
}, [])
if (!isInitialized) return null
return (
<TRPCProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
<Stack.Screen name="(app)" options={{ animation: 'fade' }} />
<Stack.Screen name="stellen-public" />
</Stack>
</TRPCProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
<Stack.Screen name="(app)" options={{ animation: 'fade' }} />
<Stack.Screen name="stellen-public/index" />
</Stack>
)
}

View File

@@ -3,5 +3,8 @@ import { useAuthStore } from '@/store/auth.store'
export default function Index() {
const session = useAuthStore((s) => s.session)
return <Redirect href={session ? '/(app)/news' : '/(auth)/login'} />
if (session) {
return <Redirect href={'/(app)/home' as never} />
}
return <Redirect href="/(auth)/login" />
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,115 @@
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { Avatar } from '@/components/ui/Avatar'
interface MemberCardProps {
member: {
id: string
name: string
betrieb: string
sparte: string
ort: string
istAusbildungsbetrieb: boolean
avatarUrl: string | null
}
onPress: () => void
}
export function MemberCard({ member, onPress }: MemberCardProps) {
return (
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.76}>
<Avatar name={member.name} imageUrl={member.avatarUrl ?? undefined} size={48} />
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{member.name}
</Text>
<Text style={styles.company} numberOfLines={1}>
{member.betrieb}
</Text>
<View style={styles.tagsRow}>
<View style={styles.tag}>
<Text style={styles.tagText}>{member.sparte}</Text>
</View>
<Text style={styles.separator}>·</Text>
<Text style={styles.location}>{member.ort}</Text>
{member.istAusbildungsbetrieb && (
<View style={styles.ausbildungTag}>
<Text style={styles.ausbildungText}>Ausbildung</Text>
</View>
)}
</View>
</View>
<Ionicons name="chevron-forward" size={18} color="#D4D4D8" />
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 14,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
shadowColor: '#1C1917',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 3,
},
info: {
flex: 1,
minWidth: 0,
},
name: {
fontSize: 15,
fontWeight: '600',
color: '#0F172A',
letterSpacing: -0.2,
},
company: {
fontSize: 13,
color: '#475569',
marginTop: 2,
},
tagsRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: 6,
marginTop: 6,
},
tag: {
backgroundColor: '#F4F4F5',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 99,
},
tagText: {
fontSize: 11,
color: '#475569',
fontWeight: '500',
},
separator: {
fontSize: 11,
color: '#D4D4D8',
},
location: {
fontSize: 11,
color: '#64748B',
},
ausbildungTag: {
backgroundColor: '#F0FDF4',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 99,
},
ausbildungText: {
fontSize: 11,
color: '#15803D',
fontWeight: '600',
},
})

View File

@@ -0,0 +1,105 @@
import { TouchableOpacity, Text, View, StyleSheet, Platform } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import * as WebBrowser from 'expo-web-browser'
interface Attachment {
id: string
name: string
storagePath: string
sizeBytes: number | null
mimeType?: string | null
}
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
function getFileIcon(mimeType?: string | null): keyof typeof Ionicons.glyphMap {
if (!mimeType) return 'document-outline'
if (mimeType.includes('pdf')) return 'document-text-outline'
if (mimeType.startsWith('image/')) return 'image-outline'
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'grid-outline'
if (mimeType.includes('word') || mimeType.includes('document')) return 'document-outline'
return 'attach-outline'
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function AttachmentRow({ attachment }: { attachment: Attachment }) {
const url = `${API_URL}/uploads/${attachment.storagePath}`
const icon = getFileIcon(attachment.mimeType)
return (
<TouchableOpacity
onPress={() => WebBrowser.openBrowserAsync(url)}
style={styles.row}
activeOpacity={0.75}
>
<View style={styles.iconBox}>
<Ionicons name={icon} size={20} color="#003B7E" />
</View>
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{attachment.name}
</Text>
{attachment.sizeBytes != null && (
<Text style={styles.meta}>{formatSize(attachment.sizeBytes)}</Text>
)}
</View>
<View style={styles.openChip}>
<Ionicons name="download-outline" size={13} color="#003B7E" />
<Text style={styles.openText}>Öffnen</Text>
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 13,
},
iconBox: {
width: 42,
height: 42,
borderRadius: 12,
backgroundColor: '#EFF6FF',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
info: {
flex: 1,
},
name: {
fontSize: 13,
fontWeight: '600',
color: '#0F172A',
lineHeight: 18,
},
meta: {
fontSize: 11,
color: '#94A3B8',
marginTop: 2,
},
openChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: '#EFF6FF',
paddingHorizontal: 11,
paddingVertical: 6,
borderRadius: 99,
},
openText: {
fontSize: 12,
color: '#003B7E',
fontWeight: '600',
},
})

View File

@@ -0,0 +1,133 @@
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { Badge } from '@/components/ui/Badge'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import { useNewsReadStore } from '@/store/news.store'
interface NewsCardProps {
news: {
id: string
title: string
kategorie: string
publishedAt: Date | null
isRead: boolean
author: { name: string } | null
}
onPress: () => void
}
export function NewsCard({ news, onPress }: NewsCardProps) {
const localReadIds = useNewsReadStore((s) => s.readIds)
const isRead = news.isRead || localReadIds.has(news.id)
return (
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.84}>
{!isRead && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>Neu</Text>
</View>
)}
<View style={styles.content}>
<View style={styles.topRow}>
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
<Text style={styles.dateText}>
{news.publishedAt
? format(new Date(news.publishedAt), 'dd. MMM', { locale: de })
: 'Entwurf'}
</Text>
</View>
<Text style={isRead ? styles.titleRead : styles.titleUnread} numberOfLines={2}>
{news.title}
</Text>
<View style={styles.metaRow}>
<View style={styles.dot} />
<Text style={styles.authorText}>{news.author?.name ?? 'Innung'}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: '#E2E8F0',
borderRadius: 16,
flexDirection: 'row',
alignItems: 'center',
padding: 14,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 10,
elevation: 2,
position: 'relative',
},
newBadge: {
position: 'absolute',
right: 30,
top: 8,
borderRadius: 999,
backgroundColor: '#EF4444',
paddingHorizontal: 8,
paddingVertical: 2,
zIndex: 2,
},
newBadgeText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '700',
},
content: {
flex: 1,
marginRight: 10,
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
dateText: {
fontSize: 11,
color: '#94A3B8',
fontWeight: '600',
},
titleUnread: {
fontSize: 15,
fontWeight: '700',
color: '#0F172A',
lineHeight: 21,
marginBottom: 8,
},
titleRead: {
fontSize: 14,
fontWeight: '500',
color: '#475569',
lineHeight: 20,
marginBottom: 8,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
dot: {
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: '#CBD5E1',
},
authorText: {
fontSize: 11,
color: '#64748B',
},
})

View File

@@ -0,0 +1,162 @@
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
const SPARTE_COLOR: Record<string, string> = {
elektro: '#1D4ED8',
sanit: '#0F766E',
it: '#4338CA',
heizung: '#B45309',
maler: '#15803D',
info: '#4338CA',
}
function getSparteColor(sparte: string): string {
const lower = sparte.toLowerCase()
for (const [key, color] of Object.entries(SPARTE_COLOR)) {
if (lower.includes(key)) return color
}
return '#003B7E'
}
interface StelleCardProps {
stelle: {
id: string
sparte: string
stellenAnz: number
verguetung: string | null
lehrjahr: string | null
member: { betrieb: string; ort: string }
org: { name: string }
}
onPress: () => void
}
export function StelleCard({ stelle, onPress }: StelleCardProps) {
const color = getSparteColor(stelle.sparte)
const initial = stelle.sparte.charAt(0).toUpperCase()
return (
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.82}>
<View style={styles.row}>
<View style={[styles.iconBox, { backgroundColor: `${color}18` }]}>
<Text style={[styles.iconLetter, { color }]}>{initial}</Text>
</View>
<View style={styles.content}>
<Text style={styles.company} numberOfLines={1}>
{stelle.member.betrieb}
</Text>
<Text style={styles.sparte}>{stelle.sparte}</Text>
<View style={styles.chips}>
<View style={styles.chipBrand}>
<Text style={styles.chipBrandText}>
{stelle.stellenAnz} Stelle{stelle.stellenAnz > 1 ? 'n' : ''}
</Text>
</View>
{stelle.lehrjahr ? (
<View style={styles.chip}>
<Text style={styles.chipText}>{stelle.lehrjahr}</Text>
</View>
) : null}
{stelle.verguetung ? (
<View style={styles.chip}>
<Text style={styles.chipText}>{stelle.verguetung}</Text>
</View>
) : null}
</View>
<View style={styles.locationRow}>
<Ionicons name="location-outline" size={12} color="#64748B" />
<Text style={styles.location}>{stelle.member.ort}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
borderWidth: 1,
borderColor: '#E2E8F0',
padding: 14,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
iconBox: {
width: 48,
height: 48,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
iconLetter: {
fontSize: 22,
fontWeight: '800',
},
content: {
flex: 1,
gap: 4,
},
company: {
fontSize: 15,
fontWeight: '700',
color: '#0F172A',
letterSpacing: -0.2,
},
sparte: {
fontSize: 12,
color: '#64748B',
},
chips: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginTop: 4,
},
chipBrand: {
backgroundColor: '#EFF6FF',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 99,
},
chipBrandText: {
fontSize: 11,
color: '#003B7E',
fontWeight: '700',
},
chip: {
backgroundColor: '#F1F5F9',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 99,
},
chipText: {
fontSize: 11,
color: '#475569',
fontWeight: '500',
},
locationRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 2,
},
location: {
fontSize: 11,
color: '#64748B',
},
})

View File

@@ -0,0 +1,67 @@
import { TouchableOpacity, Text, ActivityIndicator, StyleSheet } from 'react-native'
interface AnmeldeButtonProps {
terminId: string
isAngemeldet: boolean
onToggle: () => void
isLoading: boolean
}
export function AnmeldeButton({ isAngemeldet, onToggle, isLoading }: AnmeldeButtonProps) {
return (
<TouchableOpacity
onPress={onToggle}
disabled={isLoading}
style={[
styles.btn,
isAngemeldet ? styles.btnRegistered : styles.btnRegister,
isLoading && styles.disabled,
]}
activeOpacity={0.82}
>
{isLoading ? (
<ActivityIndicator color={isAngemeldet ? '#52525B' : '#FFFFFF'} />
) : (
<Text style={[styles.label, isAngemeldet && styles.labelRegistered]}>
{isAngemeldet ? '✓ Angemeldet Abmelden' : 'Jetzt anmelden'}
</Text>
)}
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
btn: {
borderRadius: 14,
paddingVertical: 15,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
btnRegister: {
backgroundColor: '#003B7E',
shadowColor: '#003B7E',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.24,
shadowRadius: 10,
elevation: 5,
},
btnRegistered: {
backgroundColor: '#F4F4F5',
borderWidth: 1,
borderColor: '#E2E8F0',
},
disabled: {
opacity: 0.5,
},
label: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
letterSpacing: 0.2,
},
labelRegistered: {
color: '#52525B',
},
})

View File

@@ -0,0 +1,253 @@
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { Badge } from '@/components/ui/Badge'
import { TERMIN_TYP_LABELS } from '@innungsapp/shared/types'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
interface TerminCardProps {
termin: {
id: string
titel: string
datum: Date
uhrzeit: string | null
ort: string | null
typ: string
isAngemeldet: boolean
teilnehmerAnzahl: number
}
onPress: () => void
onToggleAnmeldung?: () => void
isToggling?: boolean
isPast?: boolean
}
export function TerminCard({
termin,
onPress,
onToggleAnmeldung,
isToggling = false,
isPast = false,
}: TerminCardProps) {
const datum = new Date(termin.datum)
return (
<TouchableOpacity
onPress={onPress}
style={[styles.card, isPast && styles.cardPast]}
activeOpacity={0.76}
>
{/* Date column */}
<View style={[styles.dateColumn, isPast && styles.dateColumnPast]}>
<Text style={[styles.dayNumber, isPast && styles.dayNumberPast]}>
{format(datum, 'dd')}
</Text>
<Text style={[styles.monthLabel, isPast && styles.monthLabelPast]}>
{format(datum, 'MMM', { locale: de }).toUpperCase()}
</Text>
{!isPast && termin.isAngemeldet && <View style={styles.registeredDot} />}
{isPast && (
<View style={styles.pastBadge}>
<Text style={styles.pastBadgeText}>vorbei</Text>
</View>
)}
</View>
{/* Divider */}
<View style={[styles.divider, isPast && styles.dividerPast]} />
{/* Content */}
<View style={styles.content}>
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
<Text style={[styles.title, isPast && styles.titlePast]} numberOfLines={2}>
{termin.titel}
</Text>
<View style={styles.metaRow}>
{termin.uhrzeit && (
<View style={styles.metaItem}>
<Ionicons name="time-outline" size={12} color={isPast ? '#94A3B8' : '#64748B'} />
<Text style={[styles.metaText, isPast && styles.metaTextPast]}>
{termin.uhrzeit} Uhr
</Text>
</View>
)}
{termin.ort && (
<View style={styles.metaItem}>
<Ionicons name="location-outline" size={12} color={isPast ? '#94A3B8' : '#64748B'} />
<Text style={[styles.metaText, isPast && styles.metaTextPast]} numberOfLines={1}>
{termin.ort}
</Text>
</View>
)}
</View>
{/* Registration status chip — only for upcoming events */}
{!isPast && termin.isAngemeldet && onToggleAnmeldung && (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation?.()
onToggleAnmeldung()
}}
style={styles.registeredChip}
disabled={isToggling}
activeOpacity={0.75}
>
{isToggling ? (
<ActivityIndicator size="small" color="#047857" style={{ marginRight: 4 }} />
) : (
<Ionicons name="checkmark-circle" size={14} color="#059669" />
)}
<Text style={styles.registeredChipText}>
Angemeldet · <Text style={styles.abmeldenText}>Abmelden</Text>
</Text>
</TouchableOpacity>
)}
</View>
{/* Chevron */}
<View style={styles.chevronWrap}>
<Ionicons name="chevron-forward" size={20} color={isPast ? '#E2E8F0' : '#CBD5E1'} />
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
flexDirection: 'row',
alignItems: 'center',
overflow: 'hidden',
shadowColor: '#1C1917',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 3,
},
cardPast: {
shadowOpacity: 0.04,
elevation: 1,
},
dateColumn: {
width: 64,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
backgroundColor: '#EFF6FF',
},
dateColumnPast: {
backgroundColor: '#F4F4F5',
},
dayNumber: {
fontSize: 28,
fontWeight: '800',
color: '#003B7E',
lineHeight: 30,
},
dayNumberPast: {
color: '#94A3B8',
},
monthLabel: {
fontSize: 10,
fontWeight: '700',
color: '#003B7E',
letterSpacing: 1,
opacity: 0.75,
marginTop: 1,
},
monthLabelPast: {
color: '#94A3B8',
opacity: 1,
},
registeredDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#059669',
marginTop: 6,
},
pastBadge: {
marginTop: 6,
backgroundColor: '#E2E8F0',
borderRadius: 99,
paddingHorizontal: 6,
paddingVertical: 2,
},
pastBadgeText: {
fontSize: 9,
fontWeight: '700',
color: '#94A3B8',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
divider: {
width: 1,
alignSelf: 'stretch',
marginVertical: 14,
backgroundColor: '#F0EDED',
},
dividerPast: {
backgroundColor: '#F4F4F5',
},
content: {
flex: 1,
paddingHorizontal: 14,
paddingVertical: 14,
gap: 8,
},
title: {
fontSize: 15,
fontWeight: '600',
color: '#0F172A',
letterSpacing: -0.2,
lineHeight: 21,
marginTop: 2,
},
titlePast: {
color: '#94A3B8',
fontWeight: '500',
},
metaRow: {
flexDirection: 'column',
gap: 4,
marginTop: 4,
},
metaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
metaText: {
fontSize: 12,
color: '#64748B',
},
metaTextPast: {
color: '#94A3B8',
},
registeredChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
marginTop: 4,
alignSelf: 'flex-start',
backgroundColor: '#ECFDF5',
borderRadius: 99,
paddingHorizontal: 10,
paddingVertical: 5,
borderWidth: 1,
borderColor: '#A7F3D0',
},
registeredChipText: {
fontSize: 12,
fontWeight: '600',
color: '#047857',
},
abmeldenText: {
color: '#DC2626',
fontWeight: '600',
},
chevronWrap: {
paddingRight: 12,
},
})

View File

@@ -0,0 +1,93 @@
import { View, Text, Image, ViewStyle, ImageStyle } from 'react-native'
const AVATAR_PALETTES = [
{ bg: '#003B7E', text: '#FFFFFF' },
{ bg: '#1D4ED8', text: '#FFFFFF' },
{ bg: '#059669', text: '#FFFFFF' },
{ bg: '#4338CA', text: '#FFFFFF' },
{ bg: '#B45309', text: '#FFFFFF' },
{ bg: '#0F766E', text: '#FFFFFF' },
]
function getInitials(name: string): string {
return name
.split(' ')
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('')
}
function getPalette(name: string) {
const index = name.charCodeAt(0) % AVATAR_PALETTES.length
return AVATAR_PALETTES[index]
}
interface AvatarProps {
name: string
imageUrl?: string
size?: number
shadow?: boolean
}
export function Avatar({ name, imageUrl, size = 48, shadow = false }: AvatarProps) {
const initials = getInitials(name)
const palette = getPalette(name)
const viewShadowStyle: ViewStyle = shadow
? {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12,
shadowRadius: 8,
elevation: 3,
}
: {}
const imageShadowStyle: ImageStyle = shadow
? {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12,
shadowRadius: 8,
}
: {}
if (imageUrl) {
return (
<Image
source={{ uri: imageUrl }}
style={[
{ width: size, height: size, borderRadius: size / 2 },
imageShadowStyle,
]}
/>
)
}
return (
<View
style={[
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: palette.bg,
alignItems: 'center',
justifyContent: 'center',
},
viewShadowStyle,
]}
>
<Text
style={{
color: palette.text,
fontSize: size * 0.36,
fontWeight: '700',
letterSpacing: 0.5,
}}
>
{initials}
</Text>
</View>
)
}

View File

@@ -0,0 +1,46 @@
import { View, Text, StyleSheet } from 'react-native'
const BADGE_CONFIG: Record<string, { bg: string; color: string }> = {
Wichtig: { bg: '#FEE2E2', color: '#B91C1C' },
Pruefung: { bg: '#DBEAFE', color: '#1D4ED8' },
Foerderung: { bg: '#DCFCE7', color: '#15803D' },
Veranstaltung: { bg: '#E0E7FF', color: '#4338CA' },
Allgemein: { bg: '#F1F5F9', color: '#475569' },
Versammlung: { bg: '#E0E7FF', color: '#4338CA' },
Kurs: { bg: '#DCFCE7', color: '#15803D' },
Event: { bg: '#FEF3C7', color: '#B45309' },
Sonstiges: { bg: '#F1F5F9', color: '#475569' },
}
interface BadgeProps {
label: string
kategorie?: string
typ?: string
}
export function Badge({ label, kategorie, typ }: BadgeProps) {
const cfg =
(kategorie && BADGE_CONFIG[kategorie]) ||
(typ && BADGE_CONFIG[typ]) ||
{ bg: '#F1F5F9', color: '#475569' }
return (
<View style={[styles.pill, { backgroundColor: cfg.bg }]}>
<Text style={[styles.label, { color: cfg.color }]}>{label}</Text>
</View>
)
}
const styles = StyleSheet.create({
pill: {
alignSelf: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 99,
},
label: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.2,
},
})

View File

@@ -1,47 +1,69 @@
import { TouchableOpacity, Text, ActivityIndicator } from 'react-native'
import { Text, TouchableOpacity, ActivityIndicator } from 'react-native'
import { cn } from '../../lib/utils'
interface ButtonProps {
label: string
onPress: () => void
variant?: 'primary' | 'secondary' | 'ghost'
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline'
size?: 'default' | 'sm' | 'lg'
loading?: boolean
disabled?: boolean
icon?: string
className?: string
}
export function Button({
label,
onPress,
variant = 'primary',
size = 'default',
loading,
disabled,
icon,
className,
}: ButtonProps) {
const base = 'rounded-2xl py-4 flex-row items-center justify-center gap-2'
const variants = {
primary: `${base} bg-brand-500`,
secondary: `${base} bg-white border border-gray-200`,
ghost: `${base}`,
}
const textVariants = {
primary: 'text-white font-semibold text-base',
secondary: 'text-gray-900 font-semibold text-base',
ghost: 'text-brand-500 font-semibold text-base',
}
const isDisabled = disabled || loading
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled || loading}
className={`${variants[variant]} ${disabled || loading ? 'opacity-50' : ''}`}
disabled={isDisabled}
className={cn(
'flex-row items-center justify-center rounded-xl',
{
'bg-primary shadow-sm': variant === 'primary',
'bg-secondary border border-border': variant === 'secondary',
'bg-transparent': variant === 'ghost',
'bg-destructive': variant === 'destructive',
'bg-background border border-input': variant === 'outline',
'h-10 px-4 py-2': size === 'default',
'h-9 rounded-md px-3': size === 'sm',
'h-11 rounded-md px-8': size === 'lg',
'opacity-50': isDisabled,
},
className
)}
activeOpacity={0.8}
>
{loading ? (
<ActivityIndicator color={variant === 'primary' ? 'white' : '#E63946'} />
<ActivityIndicator
color={variant === 'primary' || variant === 'destructive' ? 'white' : 'black'}
/>
) : (
<>
{icon && <Text className="text-xl">{icon}</Text>}
<Text className={textVariants[variant]}>{label}</Text>
</>
<Text
className={cn(
'text-sm font-medium',
{
'text-primary-foreground': variant === 'primary',
'text-secondary-foreground': variant === 'secondary',
'text-primary': variant === 'ghost',
'text-destructive-foreground': variant === 'destructive',
'text-foreground': variant === 'outline',
}
)}
>
{label}
</Text>
)}
</TouchableOpacity>
)

View File

@@ -1,25 +1,39 @@
import { View, TouchableOpacity } from 'react-native'
import { View, TouchableOpacity, ViewStyle } from 'react-native'
import { cn } from '../../lib/utils'
// Keep shadow style for now as it's often better handled natively than via Tailwind utilities for cross-platform consistency
// OR use nativewind shadow classes if configured properly. Let's use Tailwind classes for shadow to align with the system if possible,
// but often standard CSS shadows don't map perfectly to RN shadow props (elevation vs shadowOffset).
// For specific "card" feel, we might want to keep a consistent shadow.
// However, the prompt asked for "10x better" design.
interface CardProps {
children: React.ReactNode
onPress?: () => void
className?: string
noPadding?: boolean
}
export function Card({ children, onPress, className = '' }: CardProps) {
export function Card({ children, onPress, className = '', noPadding = false }: CardProps) {
const baseClasses = cn(
'bg-card rounded-xl border border-border shadow-sm',
!noPadding && 'p-4',
className
)
if (onPress) {
return (
<TouchableOpacity
onPress={onPress}
className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}
activeOpacity={0.7}
className={baseClasses}
activeOpacity={0.8}
>
{children}
</TouchableOpacity>
)
}
return (
<View className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}>
<View className={baseClasses}>
{children}
</View>
)

View File

@@ -0,0 +1,59 @@
import { Ionicons } from '@expo/vector-icons'
import { View, Text, StyleSheet } from 'react-native'
interface EmptyStateProps {
icon: keyof typeof Ionicons.glyphMap
title: string
subtitle: string
}
export function EmptyState({ icon, title, subtitle }: EmptyStateProps) {
return (
<View style={styles.container}>
<View style={styles.iconBox}>
<Ionicons name={icon} size={32} color="#94A3B8" />
</View>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 80,
paddingHorizontal: 32,
},
iconBox: {
width: 72,
height: 72,
backgroundColor: '#FFFFFF',
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 18,
shadowColor: '#1C1917',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 10,
elevation: 2,
},
title: {
fontSize: 17,
fontWeight: '700',
color: '#0F172A',
textAlign: 'center',
letterSpacing: -0.2,
},
subtitle: {
fontSize: 13,
color: '#64748B',
textAlign: 'center',
marginTop: 6,
lineHeight: 19,
},
})

View File

@@ -0,0 +1,9 @@
import { View, ActivityIndicator } from 'react-native'
export function LoadingSpinner() {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#003B7E" />
</View>
)
}

View File

@@ -0,0 +1,36 @@
{
"cli": {
"version": ">= 10.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": {
"EXPO_PUBLIC_API_URL": "http://localhost:3000"
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "YOUR_APPLE_ID",
"ascAppId": "YOUR_APP_STORE_CONNECT_APP_ID",
"appleTeamId": "YOUR_APPLE_TEAM_ID"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal"
}
}
}
}

View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
}
]);

View File

@@ -1,3 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.75rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}

View File

@@ -2,7 +2,7 @@ import { useAuthStore } from '@/store/auth.store'
import { useRouter } from 'expo-router'
export function useAuth() {
const { session, orgId, role, signOut } = useAuthStore()
const { session, signOut } = useAuthStore()
const router = useRouter()
async function handleSignOut() {
@@ -12,10 +12,10 @@ export function useAuth() {
return {
session,
orgId,
role,
isAuthenticated: !!session,
isAdmin: role === 'admin',
orgId: 'org-1',
role: 'member' as const,
isAuthenticated: true, // Mock: immer eingeloggt
isAdmin: false,
signOut: handleSignOut,
}
}

View File

@@ -1,19 +1,31 @@
import { trpc } from '@/lib/trpc'
import { MOCK_MEMBERS } from '@/lib/mock-data'
import { useMembersFilterStore } from '@/store/members.store'
export function useMembersList() {
const search = useMembersFilterStore((s) => s.search)
const nurAusbildungsbetriebe = useMembersFilterStore(
(s) => s.nurAusbildungsbetriebe
)
const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe)
return trpc.members.list.useQuery({
search: search || undefined,
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
status: 'aktiv',
})
let data = MOCK_MEMBERS.filter((m) => m.status === 'aktiv')
if (search) {
const q = search.toLowerCase()
data = data.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.betrieb.toLowerCase().includes(q) ||
m.ort.toLowerCase().includes(q) ||
m.sparte.toLowerCase().includes(q)
)
}
if (nurAusbildungsbetriebe) {
data = data.filter((m) => m.istAusbildungsbetrieb)
}
return { data, isLoading: false, refetch: () => {}, isRefetching: false }
}
export function useMemberDetail(id: string) {
return trpc.members.byId.useQuery({ id })
const data = MOCK_MEMBERS.find((m) => m.id === id) ?? null
return { data, isLoading: false }
}

View File

@@ -1,22 +1,28 @@
import { trpc } from '@/lib/trpc'
import { useState } from 'react'
import { MOCK_NEWS } from '@/lib/mock-data'
import { useNewsReadStore } from '@/store/news.store'
export function useNewsList(kategorie?: string) {
return trpc.news.list.useQuery({
kategorie: kategorie as never,
})
const localReadIds = useNewsReadStore((s) => s.readIds)
const filtered = kategorie
? MOCK_NEWS.filter((n) => n.kategorie === kategorie)
: MOCK_NEWS
const data = filtered.map((n) => ({
...n,
isRead: n.isRead || localReadIds.has(n.id),
}))
return { data, isLoading: false, refetch: () => {}, isRefetching: false }
}
export function useNewsDetail(id: string) {
const markRead = useNewsReadStore((s) => s.markRead)
const markReadMutation = trpc.news.markRead.useMutation()
const query = trpc.news.byId.useQuery({ id })
const news = MOCK_NEWS.find((n) => n.id === id) ?? null
function onOpen() {
markRead(id)
markReadMutation.mutate({ newsId: id })
}
return { ...query, onOpen }
return { data: news, isLoading: false, onOpen }
}

View File

@@ -1,12 +1,13 @@
import { trpc } from '@/lib/trpc'
import { MOCK_STELLEN } from '@/lib/mock-data'
export function useStellenListe(opts?: { sparte?: string; lehrjahr?: string }) {
return trpc.stellen.listPublic.useQuery({
sparte: opts?.sparte,
lehrjahr: opts?.lehrjahr,
})
let data = MOCK_STELLEN.filter((s) => s.aktiv)
if (opts?.sparte) data = data.filter((s) => s.sparte === opts.sparte)
if (opts?.lehrjahr) data = data.filter((s) => s.lehrjahr === opts.lehrjahr)
return { data, isLoading: false, refetch: () => {}, isRefetching: false }
}
export function useStelleDetail(id: string) {
return trpc.stellen.byId.useQuery({ id })
const data = MOCK_STELLEN.find((s) => s.id === id) ?? null
return { data, isLoading: false }
}

View File

@@ -1,19 +1,33 @@
import { trpc } from '@/lib/trpc'
import { useState } from 'react'
import { MOCK_TERMINE } from '@/lib/mock-data'
export function useTermineListe(upcoming = true) {
return trpc.termine.list.useQuery({ upcoming })
const now = new Date()
const data = MOCK_TERMINE.filter((t) =>
upcoming ? t.datum >= now : t.datum < now
).sort((a, b) =>
upcoming ? a.datum.getTime() - b.datum.getTime() : b.datum.getTime() - a.datum.getTime()
)
return { data, isLoading: false, refetch: () => {}, isRefetching: false }
}
export function useTerminDetail(id: string) {
return trpc.termine.byId.useQuery({ id })
const data = MOCK_TERMINE.find((t) => t.id === id) ?? null
return { data, isLoading: false }
}
export function useToggleAnmeldung() {
const utils = trpc.useUtils()
return trpc.termine.toggleAnmeldung.useMutation({
onSuccess: () => {
utils.termine.list.invalidate()
utils.termine.byId.invalidate()
},
})
const [isPending, setIsPending] = useState(false)
function mutate({ terminId }: { terminId: string }) {
setIsPending(true)
const termin = MOCK_TERMINE.find((t) => t.id === terminId)
if (termin) {
termin.isAngemeldet = !termin.isAngemeldet
termin.teilnehmerAnzahl += termin.isAngemeldet ? 1 : -1
}
setTimeout(() => setIsPending(false), 300)
}
return { mutate, isPending }
}

View File

@@ -0,0 +1,311 @@
// Mock-Daten für Entwicklung ohne Backend
export const MOCK_ORG = {
id: 'org-1',
name: 'Innung Elektrotechnik Stuttgart',
slug: 'innung-elektro-stuttgart',
plan: 'pilot',
primaryColor: '#003B7E',
}
export const MOCK_MEMBER_ME = {
id: 'member-1',
orgId: 'org-1',
userId: 'user-1',
name: 'Demo Admin',
betrieb: 'Innungsgeschäftsstelle',
sparte: 'Elektrotechnik',
ort: 'Stuttgart',
telefon: '+49 711 123456',
email: 'admin@demo.de',
status: 'aktiv' as const,
istAusbildungsbetrieb: false,
seit: 2020,
avatarUrl: null,
pushToken: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
org: MOCK_ORG,
}
export const MOCK_MEMBERS = [
{
id: 'member-1',
orgId: 'org-1',
userId: 'user-1',
name: 'Klaus Müller',
betrieb: 'Elektro Müller GmbH',
sparte: 'Elektrotechnik',
ort: 'Stuttgart',
telefon: '+49 711 123456',
email: 'mueller@elektro-mueller.de',
status: 'aktiv' as const,
istAusbildungsbetrieb: true,
seit: 2015,
avatarUrl: null,
pushToken: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
{
id: 'member-2',
orgId: 'org-1',
userId: null,
name: 'Maria Schmidt',
betrieb: 'Schmidt Elektrik',
sparte: 'Elektrotechnik',
ort: 'Ludwigsburg',
telefon: '+49 7141 987654',
email: 'schmidt@schmidt-elektrik.de',
status: 'aktiv' as const,
istAusbildungsbetrieb: false,
seit: 2018,
avatarUrl: null,
pushToken: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
{
id: 'member-3',
orgId: 'org-1',
userId: null,
name: 'Thomas Weber',
betrieb: 'Weber & Söhne Elektro',
sparte: 'Informationstechnik',
ort: 'Esslingen',
telefon: '+49 711 555123',
email: 'weber@weber-elektro.de',
status: 'aktiv' as const,
istAusbildungsbetrieb: true,
seit: 2012,
avatarUrl: null,
pushToken: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
{
id: 'member-4',
orgId: 'org-1',
userId: null,
name: 'Anna Bauer',
betrieb: 'Bauer Elektrotechnik',
sparte: 'Elektrotechnik',
ort: 'Stuttgart',
telefon: '+49 711 444567',
email: 'bauer@bauer-elektro.de',
status: 'aktiv' as const,
istAusbildungsbetrieb: false,
seit: 2021,
avatarUrl: null,
pushToken: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
{
id: 'member-5',
orgId: 'org-1',
userId: null,
name: 'Peter Hoffmann',
betrieb: 'Hoffmann Elektro-Service',
sparte: 'Sanitär',
ort: 'Böblingen',
telefon: '+49 7031 123789',
email: 'hoffmann@elektro-service.de',
status: 'aktiv' as const,
istAusbildungsbetrieb: true,
seit: 2010,
avatarUrl: null,
pushToken: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
]
export const MOCK_NEWS = [
{
id: 'news-1',
orgId: 'org-1',
authorId: 'member-1',
title: 'Wichtige Änderungen bei der Gesellenprüfung 2025',
body: `## Änderungen ab Herbst 2025\n\nDie Prüfungsordnung wurde angepasst. Folgende Änderungen sind zu beachten:\n\n- Prüfungsteil A (Gesellenstück) wird auf 2 Tage ausgeweitet\n- Neue digitale Komponenten in Prüfungsteil B\n- Anmeldeschluss ist der 15. September 2025\n\nWeitere Details entnehmen Sie dem beigefügten Rundschreiben.`,
kategorie: 'Pruefung' as const,
publishedAt: new Date('2025-09-01'),
createdAt: new Date('2025-09-01'),
isRead: false,
author: { name: 'Klaus Müller' },
attachments: [
{
id: 'att-1',
newsId: 'news-1',
name: 'Prüfungsordnung_2025.pdf',
storagePath: 'pruefungsordnung.pdf',
mimeType: 'application/pdf',
sizeBytes: 245000,
createdAt: new Date('2025-09-01'),
},
],
},
{
id: 'news-2',
orgId: 'org-1',
authorId: 'member-1',
title: 'Förderung für Ausbildungsbetriebe: Jetzt beantragen!',
body: `## Neue Fördergelder verfügbar\n\nDas Land Baden-Württemberg stellt für das Jahr 2025 zusätzliche Fördermittel für Ausbildungsbetriebe bereit.\n\nFörderhöhe: Bis zu 3.000 € pro Auszubildenden\n\nAnträge bis 31. März 2025 einreichen.`,
kategorie: 'Foerderung' as const,
publishedAt: new Date('2025-08-15'),
createdAt: new Date('2025-08-15'),
isRead: true,
author: { name: 'Maria Schmidt' },
attachments: [],
},
{
id: 'news-3',
orgId: 'org-1',
authorId: 'member-1',
title: 'Innungsversammlung Winter 2025 — Einladung',
body: `## Einladung zur Winterversammlung\n\nWir laden herzlich zur jährlichen Winterversammlung ein.\n\nDatum: 3. Dezember 2025\nUhrzeit: 19:00 Uhr\nOrt: Gasthof Zum Lamm, Stuttgart\n\nTagesordnung:\n1. Jahresrückblick\n2. Vorstandswahlen\n3. Verschiedenes`,
kategorie: 'Veranstaltung' as const,
publishedAt: new Date('2025-11-01'),
createdAt: new Date('2025-11-01'),
isRead: false,
author: { name: 'Klaus Müller' },
attachments: [],
},
{
id: 'news-4',
orgId: 'org-1',
authorId: 'member-1',
title: 'Neue Tarifvereinbarung ab Januar 2026',
body: `## Tarifeinigung im Elektrohandwerk\n\nDie Tarifverhandlungen wurden erfolgreich abgeschlossen. Ab Januar 2026 gelten folgende Sätze:\n\n- Gesellenlohn: +4,2 %\n- Ausbildungsvergütung: +5,0 %`,
kategorie: 'Wichtig' as const,
publishedAt: new Date('2025-10-20'),
createdAt: new Date('2025-10-20'),
isRead: false,
author: { name: 'Klaus Müller' },
attachments: [],
},
{
id: 'news-5',
orgId: 'org-1',
authorId: 'member-1',
title: 'Mitgliederrundschreiben Oktober 2025',
body: `## Allgemeine Informationen\n\nLiebe Innungsmitglieder,\n\nin dieser Ausgabe informieren wir Sie über aktuelle Themen aus dem Elektrohandwerk.`,
kategorie: 'Allgemein' as const,
publishedAt: new Date('2025-10-01'),
createdAt: new Date('2025-10-01'),
isRead: true,
author: { name: 'Klaus Müller' },
attachments: [],
},
]
export const MOCK_TERMINE = [
{
id: 'termin-1',
orgId: 'org-1',
titel: 'Herbst-Gesellenprüfung 2025',
datum: new Date('2025-11-15'),
uhrzeit: '08:00',
endeDatum: new Date('2025-11-16'),
endeUhrzeit: '17:00',
ort: 'Berufsschule Stuttgart-Mitte',
adresse: 'Neckarstraße 22, 70190 Stuttgart',
typ: 'Pruefung' as const,
beschreibung: 'Praktische und theoretische Gesellenprüfung für Elektrotechniker.',
maxTeilnehmer: 30,
createdAt: new Date('2025-09-01'),
isAngemeldet: false,
teilnehmerAnzahl: 18,
anmeldungen: [],
},
{
id: 'termin-2',
orgId: 'org-1',
titel: 'Innungsversammlung Winter 2025',
datum: new Date('2025-12-03'),
uhrzeit: '19:00',
endeDatum: null,
endeUhrzeit: '21:00',
ort: 'Gasthof Zum Lamm',
adresse: 'Marktplatz 5, 70173 Stuttgart',
typ: 'Versammlung' as const,
beschreibung: 'Jährliche Winterversammlung mit Jahresrückblick und Vorstandswahlen.',
maxTeilnehmer: null,
createdAt: new Date('2025-10-01'),
isAngemeldet: true,
teilnehmerAnzahl: 42,
anmeldungen: [],
},
{
id: 'termin-3',
orgId: 'org-1',
titel: 'Weiterbildung: Photovoltaik & Elektromobilität',
datum: new Date('2026-02-20'),
uhrzeit: '09:00',
endeDatum: null,
endeUhrzeit: '17:00',
ort: 'Handwerkskammer Stuttgart',
adresse: 'Heilbronner Straße 43, 70191 Stuttgart',
typ: 'Kurs' as const,
beschreibung: 'Zertifizierter Lehrgang für PV-Anlagen und E-Mobilität. Anmeldung erforderlich.',
maxTeilnehmer: 20,
createdAt: new Date('2025-12-01'),
isAngemeldet: false,
teilnehmerAnzahl: 14,
anmeldungen: [],
},
]
export const MOCK_STELLEN = [
{
id: 'stelle-1',
orgId: 'org-1',
memberId: 'member-1',
sparte: 'Elektrotechnik',
stellenAnz: 2,
verguetung: '620-750 € / Monat',
lehrjahr: 'beliebig',
beschreibung: 'Wir suchen motivierte Auszubildende für unser erfolgreiches Elektroinstallationsunternehmen. Moderner Fuhrpark, faire Vergütung, Übernahmechancen.',
kontaktEmail: 'mueller@elektro-mueller.de',
kontaktName: 'Klaus Müller',
aktiv: true,
createdAt: new Date('2025-09-01'),
updatedAt: new Date('2025-09-01'),
member: { betrieb: 'Elektro Müller GmbH', ort: 'Stuttgart' },
org: { name: 'Innung Elektrotechnik Stuttgart', slug: 'innung-elektro-stuttgart' },
},
{
id: 'stelle-2',
orgId: 'org-1',
memberId: 'member-3',
sparte: 'Informationstechnik',
stellenAnz: 1,
verguetung: '650-800 € / Monat',
lehrjahr: '1. Lehrjahr',
beschreibung: 'Weber & Söhne sucht einen Auszubildenden im Bereich IT-Systemelektronik. Gute Übernahmechancen nach erfolgreichem Abschluss.',
kontaktEmail: 'weber@weber-elektro.de',
kontaktName: 'Thomas Weber',
aktiv: true,
createdAt: new Date('2025-10-01'),
updatedAt: new Date('2025-10-01'),
member: { betrieb: 'Weber & Söhne Elektro', ort: 'Esslingen' },
org: { name: 'Innung Elektrotechnik Stuttgart', slug: 'innung-elektro-stuttgart' },
},
{
id: 'stelle-3',
orgId: 'org-1',
memberId: 'member-5',
sparte: 'Sanitär',
stellenAnz: 3,
verguetung: '580-700 € / Monat',
lehrjahr: 'beliebig',
beschreibung: 'Familienbetrieb sucht Auszubildende für Sanitär- und Heizungsinstallation.',
kontaktEmail: 'hoffmann@elektro-service.de',
kontaktName: 'Peter Hoffmann',
aktiv: true,
createdAt: new Date('2025-11-01'),
updatedAt: new Date('2025-11-01'),
member: { betrieb: 'Hoffmann Elektro-Service', ort: 'Böblingen' },
org: { name: 'Innung Elektrotechnik Stuttgart', slug: 'innung-elektro-stuttgart' },
},
]

View File

@@ -8,6 +8,8 @@ Notifications.setNotificationHandler({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
})

View File

@@ -0,0 +1,43 @@
export const themeConfig = {
colors: {
// Brand Colors
primary: '#2563EB', // Vibrant Blue
primaryForeground: '#FFFFFF',
// UI Colors
background: '#FFFFFF',
foreground: '#0F172A', // Slate 900
card: '#FFFFFF',
cardForeground: '#0F172A',
popover: '#FFFFFF',
popoverForeground: '#0F172A',
secondary: '#F1F5F9', // Slate 100
secondaryForeground: '#0F172A',
muted: '#F1F5F9',
mutedForeground: '#64748B', // Slate 500
accent: '#F1F5F9',
accentForeground: '#0F172A',
destructive: '#EF4444', // Red 500
destructiveForeground: '#FFFFFF',
border: '#E2E8F0', // Slate 200
input: '#E2E8F0',
ring: '#2563EB',
},
layout: {
radius: {
small: 4,
medium: 8,
large: 12,
xl: 16,
'2xl': 24,
full: 9999,
}
}
} as const;

View File

@@ -36,9 +36,11 @@ const trpcClient = trpc.createClient({
})
export function TRPCProvider({ children }: { children: ReactNode }) {
return createElement(
trpc.Provider,
{ client: trpcClient, queryClient },
createElement(QueryClientProvider, { client: queryClient }, children)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
)
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,6 +1,43 @@
const path = require('path')
const { createRequire } = require('module')
const { getDefaultConfig } = require('expo/metro-config')
const { withNativeWind } = require('nativewind/metro')
const config = getDefaultConfig(__dirname)
const projectRoot = __dirname
const workspaceRoot = path.resolve(projectRoot, '../..')
const appRequire = createRequire(path.join(projectRoot, 'package.json'))
const reactEntry = appRequire.resolve('react')
const reactJsxRuntime = appRequire.resolve('react/jsx-runtime')
const reactJsxDevRuntime = appRequire.resolve('react/jsx-dev-runtime')
const reactRoot = path.dirname(appRequire.resolve('react/package.json'))
const reactNativeRoot = path.dirname(appRequire.resolve('react-native/package.json'))
module.exports = withNativeWind(config, { input: './global.css' })
const config = getDefaultConfig(projectRoot)
// Monorepo: Metro muss die Workspace-Pakete finden
config.watchFolders = [workspaceRoot]
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
]
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules || {}),
react: reactRoot,
'react-native': reactNativeRoot,
}
const originalResolveRequest = config.resolver.resolveRequest
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === 'react') {
return { type: 'sourceFile', filePath: reactEntry }
}
if (moduleName === 'react/jsx-runtime') {
return { type: 'sourceFile', filePath: reactJsxRuntime }
}
if (moduleName === 'react/jsx-dev-runtime') {
return { type: 'sourceFile', filePath: reactJsxDevRuntime }
}
const resolver = originalResolveRequest ?? context.resolveRequest
return resolver(context, moduleName, platform)
}
module.exports = config

View File

@@ -0,0 +1,289 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.withCssInterop = withCssInterop;
const fs_1 = __importDefault(require("fs"));
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const module_1 = require("module");
const nativewindRequire = (0, module_1.createRequire)(require.resolve("nativewind/metro"));
const connect_1 = __importDefault(nativewindRequire("connect"));
const debug_1 = nativewindRequire("debug");
const css_to_rn_1 = nativewindRequire("react-native-css-interop/dist/css-to-rn");
const expo_1 = nativewindRequire("react-native-css-interop/dist/metro/expo");
let haste;
let virtualModulesPossible = undefined;
const virtualModules = new Map();
const outputDirectory = path_1.default.resolve(__dirname, "../.cache/react-native-css-interop");
const isRadonIDE = "REACT_NATIVE_IDE_LIB_PATH" in process.env;
function withCssInterop(config, options) {
return typeof config === "function"
? async function WithCSSInterop() {
return getConfig(await config(), options);
}
: getConfig(config, options);
}
function getConfig(config, options) {
const debug = (0, debug_1.debug)(options.parent?.name || "react-native-css-interop");
debug("withCssInterop");
debug(`outputDirectory ${outputDirectory}`);
debug(`isRadonIDE: ${isRadonIDE}`);
(0, expo_1.expoColorSchemeWarning)();
const originalResolver = config.resolver?.resolveRequest;
const originalGetTransformOptions = config.transformer?.getTransformOptions;
const originalMiddleware = config.server?.enhanceMiddleware;
const poisonPillPath = "./interop-poison.pill";
fs_1.default.mkdirSync(outputDirectory, { recursive: true });
fs_1.default.writeFileSync(platformPath("ios"), "");
fs_1.default.writeFileSync(platformPath("android"), "");
fs_1.default.writeFileSync(platformPath("native"), "");
fs_1.default.writeFileSync(platformPath("macos"), "");
fs_1.default.writeFileSync(platformPath("windows"), "");
return {
...config,
transformerPath: require.resolve("react-native-css-interop/dist/metro/transformer"),
transformer: {
...config.transformer,
...{
cssInterop_transformerPath: config.transformerPath,
cssInterop_outputDirectory: path_1.default.relative(process.cwd(), outputDirectory),
},
async getTransformOptions(entryPoints, transformOptions, getDependenciesOf) {
debug(`getTransformOptions.dev ${transformOptions.dev}`);
debug(`getTransformOptions.platform ${transformOptions.platform}`);
debug(`getTransformOptions.virtualModulesPossible ${Boolean(virtualModulesPossible)}`);
const platform = transformOptions.platform || "native";
const filePath = platformPath(platform);
if (virtualModulesPossible) {
await virtualModulesPossible;
await startCSSProcessor(filePath, platform, transformOptions.dev, options, debug);
}
const writeToFileSystem = !virtualModulesPossible || !transformOptions.dev;
debug(`getTransformOptions.writeToFileSystem ${writeToFileSystem}`);
if (writeToFileSystem) {
debug(`getTransformOptions.output ${filePath}`);
const watchFn = isRadonIDE
? async (css) => {
const output = platform === "web"
? css.toString()
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options, debug), debug);
await promises_1.default.writeFile(filePath, output);
}
: undefined;
const css = await options.getCSSForPlatform(platform, watchFn);
const output = platform === "web"
? css.toString("utf-8")
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options, debug), debug);
await promises_1.default.mkdir(outputDirectory, { recursive: true });
await promises_1.default.writeFile(filePath, output);
if (platform !== "web") {
await promises_1.default.writeFile(filePath.replace(/\.js$/, ".map"), "");
}
debug(`getTransformOptions.finished`);
}
return Object.assign({}, await originalGetTransformOptions?.(entryPoints, transformOptions, getDependenciesOf));
},
},
server: {
...config.server,
enhanceMiddleware: (middleware, metroServer) => {
debug(`enhanceMiddleware.setup`);
const server = (0, connect_1.default)();
const bundler = metroServer.getBundler().getBundler();
if (options.forceWriteFileSystem) {
debug(`forceWriteFileSystem true`);
}
else {
if (!isRadonIDE) {
virtualModulesPossible = bundler
.getDependencyGraph()
.then(async (graph) => {
haste = graph?._haste;
if (graph?._fileSystem) {
ensureFileSystemPatched(graph._fileSystem);
}
ensureBundlerPatched(bundler);
});
server.use(async (_, __, next) => {
await virtualModulesPossible;
next();
});
}
}
return originalMiddleware
? server.use(originalMiddleware(middleware, metroServer))
: server.use(middleware);
},
},
resolver: {
...config.resolver,
sourceExts: [...(config?.resolver?.sourceExts || []), "css"],
resolveRequest: (context, moduleName, platform) => {
if (moduleName === poisonPillPath) {
return { type: "empty" };
}
const resolver = originalResolver ?? context.resolveRequest;
const resolved = resolver(context, moduleName, platform);
if (!("filePath" in resolved && resolved.filePath === options.input)) {
return resolved;
}
platform = platform || "native";
const filePath = platformPath(platform);
const development = context.isDev || context.dev;
const isWebProduction = !development && platform === "web";
debug(`resolveRequest.input ${resolved.filePath}`);
debug(`resolveRequest.resolvedTo: ${filePath}`);
debug(`resolveRequest.development: ${development}`);
debug(`resolveRequest.platform: ${platform}`);
if (virtualModulesPossible && !isWebProduction) {
startCSSProcessor(filePath, platform, development, options, debug);
}
return {
...resolved,
filePath,
};
},
},
};
}
async function startCSSProcessor(filePath, platform, isDev, { input, getCSSForPlatform, ...options }, debug) {
if (virtualModules.has(filePath)) {
return;
}
debug(`virtualModules ${filePath}`);
debug(`virtualModules.isDev ${isDev}`);
debug(`virtualModules.size ${virtualModules.size}`);
options = {
cache: {
keyframes: new Map(),
rules: new Map(),
rootVariables: {},
universalVariables: {},
},
...options,
};
if (!isDev) {
debug(`virtualModules.fastRefresh disabled`);
virtualModules.set(filePath, getCSSForPlatform(platform).then((css) => {
return platform === "web"
? css
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options), debug);
}));
}
else {
debug(`virtualModules.fastRefresh enabled`);
virtualModules.set(filePath, getCSSForPlatform(platform, (css) => {
debug(`virtualStyles.update ${platform}`);
virtualModules.set(filePath, Promise.resolve(platform === "web"
? css
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options), debug)));
debug(`virtualStyles.emit ${platform}`);
if (haste?.emit) {
haste.emit("change", {
eventsQueue: [
{
filePath,
metadata: {
modifiedTime: Date.now(),
size: 1,
type: "virtual",
},
type: "change",
},
],
});
}
}).then((css) => {
debug(`virtualStyles.initial ${platform}`);
return platform === "web"
? css
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options), debug);
}));
}
}
function ensureFileSystemPatched(fs) {
if (!fs || typeof fs.getSha1 !== "function") {
return fs;
}
if (!fs.getSha1.__css_interop_patched) {
const original_getSha1 = fs.getSha1.bind(fs);
fs.getSha1 = (filename) => {
if (virtualModules.has(filename)) {
return `${filename}-${Date.now()}`;
}
return original_getSha1(filename);
};
fs.getSha1.__css_interop_patched = true;
}
return fs;
}
function ensureBundlerPatched(bundler) {
if (bundler.transformFile.__css_interop__patched) {
return;
}
const transformFile = bundler.transformFile.bind(bundler);
bundler.transformFile = async function (filePath, transformOptions, fileBuffer) {
const virtualModule = virtualModules.get(filePath);
if (virtualModule) {
fileBuffer = Buffer.from(await virtualModule);
}
return transformFile(filePath, transformOptions, fileBuffer);
};
bundler.transformFile.__css_interop__patched = true;
}
function getNativeJS(data = {}, debug) {
debug("Start stringify");
const output = `(${stringify(data)})`;
debug(`Output size: ${Buffer.byteLength(output, "utf8")} bytes`);
return output;
}
function platformPath(platform = "native") {
return path_1.default.join(outputDirectory, `${platform}.${platform === "web" ? "css" : "js"}`);
}
function stringify(data) {
switch (typeof data) {
case "bigint":
case "symbol":
case "function":
throw new Error(`Cannot stringify ${typeof data}`);
case "string":
return `"${data}"`;
case "number":
return `${Math.round(data * 1000) / 1000}`;
case "boolean":
return `${data}`;
case "undefined":
return "null";
case "object": {
if (data === null) {
return "null";
}
else if (Array.isArray(data)) {
return `[${data
.map((value) => {
return value === null || value === undefined
? ""
: stringify(value);
})
.join(",")}]`;
}
else {
return `{${Object.entries(data)
.flatMap(([key, value]) => {
if (value === null || value === undefined) {
return [];
}
if (key.match(/[^a-zA-Z]/)) {
key = `"${key}"`;
}
value = stringify(value);
return [`${key}:${value}`];
})
.join(",")}}`;
}
}
}
}
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

View File

@@ -11,42 +11,48 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^2.1.0",
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.3",
"@innungsapp/shared": "workspace:*",
"@react-native-async-storage/async-storage": "^2.2.0",
"@tanstack/react-query": "^5.59.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"better-auth": "^1.2.0",
"expo": "~52.0.0",
"expo-calendar": "~13.0.0",
"expo-constants": "~17.0.0",
"expo-dev-client": "~5.0.0",
"expo-document-picker": "~13.0.0",
"expo-font": "~13.0.0",
"expo-haptics": "~14.0.0",
"expo-linking": "~7.0.0",
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
"expo-splash-screen": "~0.29.0",
"expo-status-bar": "~2.0.0",
"expo-system-ui": "~4.0.0",
"expo-web-browser": "~14.0.0",
"nativewind": "^4.1.0",
"react": "18.3.1",
"react-native": "0.76.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.0.0",
"react-native-reanimated": "~3.16.0",
"react-native-gesture-handler": "~2.21.0",
"superjson": "^2.2.1",
"zustand": "^5.0.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"zod": "^3.23.0"
"expo": "~54.0.33",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-document-picker": "~14.0.8",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.1.0",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.6",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "~4.16.0",
"superjson": "^2.2.1",
"tailwind-merge": "^2.5.0",
"zod": "^3.23.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~18.3.0",
"@innungsapp/admin": "workspace:*",
"@types/react": "~19.1.17",
"eslint-config-expo": "~10.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
"typescript": "^5.9.3"
}
}

View File

@@ -1,58 +1,34 @@
import { create } from 'zustand'
import { authClient } from '@/lib/auth-client'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { MOCK_MEMBER_ME } from '@/lib/mock-data'
interface Session {
user: {
id: string
email: string
name: string
}
token?: string
user: { id: string; email: string; name: string }
}
interface AuthState {
session: Session | null
orgId: string | null
role: 'admin' | 'member' | null
isInitialized: boolean
initialize: () => Promise<void>
setSession: (session: Session | null) => void
signOut: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
session: null,
orgId: null,
role: null,
isInitialized: false,
// Mock: direkt eingeloggt
session: {
user: {
id: MOCK_MEMBER_ME.userId!,
email: MOCK_MEMBER_ME.email,
name: MOCK_MEMBER_ME.name,
},
},
isInitialized: true,
initialize: async () => {
try {
const { data } = await authClient.getSession()
if (data?.session && data?.user) {
set({
session: { user: data.user },
isInitialized: true,
})
// Store token for API requests
if (data.session.token) {
await AsyncStorage.setItem('better-auth-session', data.session.token)
}
} else {
await AsyncStorage.removeItem('better-auth-session')
set({ session: null, orgId: null, role: null, isInitialized: true })
}
} catch {
set({ session: null, isInitialized: true })
}
// Mock: nichts zu tun
set({ isInitialized: true })
},
setSession: (session) => set({ session }),
signOut: async () => {
await authClient.signOut()
await AsyncStorage.removeItem('better-auth-session')
set({ session: null, orgId: null, role: null })
set({ session: null })
},
}))

View File

@@ -3,16 +3,67 @@ module.exports = {
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
brand: {
50: '#fff1f1',
100: '#ffe1e1',
400: '#ff6b6b',
500: '#E63946',
600: '#d42535',
700: '#b21e2c',
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},

Binary file not shown.

View File

@@ -0,0 +1,10 @@
../admin/server/context.ts(2,22): error TS2307: Cannot find module '@/lib/auth' or its corresponding type declarations.
../admin/server/routers/members.ts(3,22): error TS2307: Cannot find module '@/lib/auth' or its corresponding type declarations.
../admin/server/routers/members.ts(4,33): error TS2307: Cannot find module '@/lib/email' or its corresponding type declarations.
../admin/server/routers/news.ts(3,10): error TS2724: '"@/lib/notifications"' has no exported member named 'sendPushNotifications'. Did you mean 'setupPushNotifications'?
lib/notifications.ts(7,35): error TS2322: Type 'Promise<{ shouldShowAlert: true; shouldPlaySound: true; shouldSetBadge: true; }>' is not assignable to type 'Promise<NotificationBehavior>'.
Type '{ shouldShowAlert: true; shouldPlaySound: true; shouldSetBadge: true; }' is missing the following properties from type 'NotificationBehavior': shouldShowBanner, shouldShowList
lib/trpc.ts(41,5): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type '{ client: TRPCClient<BuiltRouter<{ ctx: { req: Request; session: any; prisma: PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>; }; meta: object; errorShape: { ...; }; transformer: true; }, DecorateCreateRouterOptions<...>>>; queryClient: QueryClient; }' is not assignable to parameter of type 'Attributes & TRPCProviderProps<BuiltRouter<{ ctx: { req: Request; session: any; prisma: PrismaClient<PrismaClientOptions, never, DefaultArgs>; }; meta: object; errorShape: { ...; }; transformer: true; }, DecorateCreateRouterOptions<...>>, unknown>'.
Property 'children' is missing in type '{ client: TRPCClient<BuiltRouter<{ ctx: { req: Request; session: any; prisma: PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>; }; meta: object; errorShape: { ...; }; transformer: true; }, DecorateCreateRouterOptions<...>>>; queryClient: QueryClient; }' but required in type 'TRPCProviderProps<BuiltRouter<{ ctx: { req: Request; session: any; prisma: PrismaClient<PrismaClientOptions, never, DefaultArgs>; }; meta: object; errorShape: { ...; }; transformer: true; }, DecorateCreateRouterOptions<...>>, unknown>'.

View File

@@ -0,0 +1,32 @@
../admin/server/context.ts(2,22): error TS2307: Cannot find module '@/lib/auth' or its corresponding type declarations.
../admin/server/routers/members.ts(3,22): error TS2307: Cannot find module '@/lib/auth' or its corresponding type declarations.
../admin/server/routers/members.ts(4,33): error TS2307: Cannot find module '@/lib/email' or its corresponding type declarations.
../admin/server/routers/news.ts(3,10): error TS2724: '"@/lib/notifications"' has no exported member named 'sendPushNotifications'. Did you mean 'setupPushNotifications'?
../admin/server/routers/news.ts(45,24): error TS7006: Parameter 'n' implicitly has an 'any' type.
../admin/server/routers/termine.ts(55,27): error TS7006: Parameter 't' implicitly has an 'any' type.
../admin/server/routers/termine.ts(58,33): error TS7006: Parameter 'a' implicitly has an 'any' type.
../admin/server/routers/termine.ts(87,36): error TS7006: Parameter 'a' implicitly has an 'any' type.
../admin/server/routers/termine.ts(115,10): error TS7006: Parameter 'a' implicitly has an 'any' type.
lib/notifications.ts(7,35): error TS2322: Type 'Promise<{ shouldShowAlert: true; shouldPlaySound: true; shouldSetBadge: true; }>' is not assignable to type 'Promise<NotificationBehavior>'.
Type '{ shouldShowAlert: true; shouldPlaySound: true; shouldSetBadge: true; }' is missing the following properties from type 'NotificationBehavior': shouldShowBanner, shouldShowList
lib/trpc.ts(41,5): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type '{ client: TRPCClient<BuiltRouter<{ ctx: { req: Request; session: any; prisma: any; }; meta: object; errorShape: { data: { zodError: typeToFlattenedError<any, string> | null; code: TRPC_ERROR_CODE_KEY; httpStatus: number; path?: string; stack?: string; }; message: string; code: TRPC_ERROR_CODE_NUMBER; }; transformer:...' is not assignable to parameter of type 'Attributes & TRPCProviderProps<BuiltRouter<{ ctx: { req: Request; session: any; prisma: any; }; meta: object; errorShape: { data: { zodError: typeToFlattenedError<any, string> | null; code: "PARSE_ERROR" | ... 19 more ... | "CLIENT_CLOSED_REQUEST"; httpStatus: number; path?: string | undefined; stack?: string | unde...'.
Property 'children' is missing in type '{ client: TRPCClient<BuiltRouter<{ ctx: { req: Request; session: any; prisma: any; }; meta: object; errorShape: { data: { zodError: typeToFlattenedError<any, string> | null; code: TRPC_ERROR_CODE_KEY; httpStatus: number; path?: string; stack?: string; }; message: string; code: TRPC_ERROR_CODE_NUMBER; }; transformer:...' but required in type 'TRPCProviderProps<BuiltRouter<{ ctx: { req: Request; session: any; prisma: any; }; meta: object; errorShape: { data: { zodError: typeToFlattenedError<any, string> | null; code: "PARSE_ERROR" | ... 19 more ... | "CLIENT_CLOSED_REQUEST"; httpStatus: number; path?: string | undefined; stack?: string | undefined; }; mes...'.
../../packages/shared/src/types/index.ts(3,3): error TS2305: Module '"@prisma/client"' has no exported member 'User'.
../../packages/shared/src/types/index.ts(4,3): error TS2305: Module '"@prisma/client"' has no exported member 'Session'.
../../packages/shared/src/types/index.ts(5,3): error TS2305: Module '"@prisma/client"' has no exported member 'Account'.
../../packages/shared/src/types/index.ts(6,3): error TS2305: Module '"@prisma/client"' has no exported member 'Organization'.
../../packages/shared/src/types/index.ts(7,3): error TS2305: Module '"@prisma/client"' has no exported member 'Member'.
../../packages/shared/src/types/index.ts(8,3): error TS2305: Module '"@prisma/client"' has no exported member 'UserRole'.
../../packages/shared/src/types/index.ts(9,3): error TS2305: Module '"@prisma/client"' has no exported member 'News'.
../../packages/shared/src/types/index.ts(10,3): error TS2305: Module '"@prisma/client"' has no exported member 'NewsRead'.
../../packages/shared/src/types/index.ts(11,3): error TS2305: Module '"@prisma/client"' has no exported member 'NewsAttachment'.
../../packages/shared/src/types/index.ts(12,3): error TS2305: Module '"@prisma/client"' has no exported member 'Stelle'.
../../packages/shared/src/types/index.ts(13,3): error TS2305: Module '"@prisma/client"' has no exported member 'Termin'.
../../packages/shared/src/types/index.ts(14,3): error TS2305: Module '"@prisma/client"' has no exported member 'TerminAnmeldung'.
../../packages/shared/src/types/index.ts(15,3): error TS2305: Module '"@prisma/client"' has no exported member 'Plan'.
../../packages/shared/src/types/index.ts(16,3): error TS2305: Module '"@prisma/client"' has no exported member 'MemberStatus'.
../../packages/shared/src/types/index.ts(17,3): error TS2305: Module '"@prisma/client"' has no exported member 'OrgRole'.
../../packages/shared/src/types/index.ts(18,3): error TS2305: Module '"@prisma/client"' has no exported member 'NewsKategorie'.
../../packages/shared/src/types/index.ts(19,3): error TS2305: Module '"@prisma/client"' has no exported member 'TerminTyp'.

View File

@@ -4,8 +4,17 @@
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"]
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}

View File

@@ -15,7 +15,8 @@
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.6.0"
"typescript": "^5.6.0",
"undici": "6.23.0"
},
"packageManager": "pnpm@9.12.0",
"engines": {

12497
innungsapp/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff